mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-06-09 19:45:17 +02:00
Compare commits
37 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2178c1488 | ||
|
|
f6f6c715a0 | ||
|
|
8fb0833b19 | ||
|
|
46042f9465 | ||
|
|
13b5bab18f | ||
|
|
372309eb18 | ||
|
|
7f3c16cc33 | ||
|
|
97c8f9d787 | ||
|
|
05a93c98ae | ||
|
|
81cc4e10b7 | ||
|
|
08a727c5ec | ||
|
|
547a22ae1a | ||
|
|
d47cab6a0f | ||
|
|
8a8b78071d | ||
|
|
30356e36b1 | ||
|
|
caea83aecf | ||
|
|
5368751f61 | ||
|
|
732401f72e | ||
|
|
5ae853e15c | ||
|
|
78d51ccbf6 | ||
|
|
5677916790 | ||
|
|
cc034c7688 | ||
|
|
56246b84e6 | ||
|
|
129d91dc8d | ||
|
|
c213274723 | ||
|
|
e7c7d0e90f | ||
|
|
f378c7c604 | ||
|
|
537b6f66bb | ||
|
|
b89b91258e | ||
|
|
daff21481a | ||
|
|
78c5ad2e6f | ||
|
|
373d1ee92b | ||
|
|
0af48ecd4a | ||
|
|
2e930612f8 | ||
|
|
6288f99a85 | ||
|
|
2f9ce051c0 | ||
|
|
f78f1380eb |
99 changed files with 6080 additions and 3668 deletions
21
.github/workflows/electron-build.yml
vendored
21
.github/workflows/electron-build.yml
vendored
|
|
@ -16,14 +16,14 @@ jobs:
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@v4
|
uses: pnpm/action-setup@v6
|
||||||
with:
|
with:
|
||||||
version: 9
|
version: 10
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 24
|
node-version: 24.15.0
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
cache-dependency-path: 'apps/x/pnpm-lock.yaml'
|
cache-dependency-path: 'apps/x/pnpm-lock.yaml'
|
||||||
|
|
||||||
|
|
@ -111,6 +111,7 @@ jobs:
|
||||||
with:
|
with:
|
||||||
name: distributables
|
name: distributables
|
||||||
path: apps/x/apps/main/out/make/*
|
path: apps/x/apps/main/out/make/*
|
||||||
|
if-no-files-found: error
|
||||||
retention-days: 30
|
retention-days: 30
|
||||||
|
|
||||||
build-linux:
|
build-linux:
|
||||||
|
|
@ -121,14 +122,14 @@ jobs:
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@v4
|
uses: pnpm/action-setup@v6
|
||||||
with:
|
with:
|
||||||
version: 9
|
version: 10
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 24
|
node-version: 24.15.0
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
cache-dependency-path: 'apps/x/pnpm-lock.yaml'
|
cache-dependency-path: 'apps/x/pnpm-lock.yaml'
|
||||||
|
|
||||||
|
|
@ -175,6 +176,7 @@ jobs:
|
||||||
with:
|
with:
|
||||||
name: distributables-linux
|
name: distributables-linux
|
||||||
path: apps/x/apps/main/out/make/*
|
path: apps/x/apps/main/out/make/*
|
||||||
|
if-no-files-found: error
|
||||||
retention-days: 30
|
retention-days: 30
|
||||||
|
|
||||||
build-windows:
|
build-windows:
|
||||||
|
|
@ -185,14 +187,14 @@ jobs:
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@v4
|
uses: pnpm/action-setup@v6
|
||||||
with:
|
with:
|
||||||
version: 9
|
version: 10
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 24
|
node-version: 24.15.0
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
cache-dependency-path: 'apps/x/pnpm-lock.yaml'
|
cache-dependency-path: 'apps/x/pnpm-lock.yaml'
|
||||||
|
|
||||||
|
|
@ -241,4 +243,5 @@ jobs:
|
||||||
with:
|
with:
|
||||||
name: distributables-windows
|
name: distributables-windows
|
||||||
path: apps/x/apps/main/out/make/*
|
path: apps/x/apps/main/out/make/*
|
||||||
|
if-no-files-found: error
|
||||||
retention-days: 30
|
retention-days: 30
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,8 @@
|
||||||
|
|
||||||
## Event catalog
|
## 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`
|
### `llm_usage`
|
||||||
|
|
||||||
Emitted whenever ai-sdk returns token usage (one event per LLM call, not per run).
|
Emitted whenever ai-sdk returns token usage (one event per LLM call, not per run).
|
||||||
|
|
@ -101,6 +103,7 @@ 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 |
|
| `email` | main on identify | From `/v1/me`; powers PostHog cohort match + integrations |
|
||||||
| `plan`, `status` | main on identify | Subscription state |
|
| `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 |
|
| `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 |
|
| `signed_in` | renderer | `true` while rowboat OAuth is connected |
|
||||||
| `{provider}_connected` | renderer | One of `gmail`, `calendar`, `slack`, `rowboat` |
|
| `{provider}_connected` | renderer | One of `gmail`, `calendar`, `slack`, `rowboat` |
|
||||||
| `total_notes` | renderer (init) | Workspace size signal |
|
| `total_notes` | renderer (init) | Workspace size signal |
|
||||||
|
|
|
||||||
|
|
@ -10,11 +10,13 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import * as esbuild from 'esbuild';
|
import * as esbuild from 'esbuild';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
|
||||||
// In CommonJS, import.meta.url doesn't exist. We need to polyfill it.
|
// In CommonJS, import.meta.url doesn't exist. We need to polyfill it.
|
||||||
// The banner defines __import_meta_url at the top of the bundle,
|
// The banner defines __import_meta_url at the top of the bundle,
|
||||||
// and we use define to replace all import.meta.url references with it.
|
// and we use define to replace all import.meta.url references with it.
|
||||||
const cjsBanner = `var __import_meta_url = require('url').pathToFileURL(__filename).href;`;
|
const cjsBanner = `var __import_meta_url = require('url').pathToFileURL(__filename).href;`;
|
||||||
|
const pkg = JSON.parse(await readFile(new URL('./package.json', import.meta.url), 'utf8'));
|
||||||
|
|
||||||
await esbuild.build({
|
await esbuild.build({
|
||||||
entryPoints: ['./dist/main.js'],
|
entryPoints: ['./dist/main.js'],
|
||||||
|
|
@ -36,6 +38,7 @@ await esbuild.build({
|
||||||
// Empty strings disable analytics gracefully.
|
// Empty strings disable analytics gracefully.
|
||||||
'process.env.POSTHOG_KEY': JSON.stringify(process.env.VITE_PUBLIC_POSTHOG_KEY ?? ''),
|
'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.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,6 +56,7 @@ module.exports = {
|
||||||
description: 'AI coworker with memory',
|
description: 'AI coworker with memory',
|
||||||
name: `Rowboat-win32-${arch}`,
|
name: `Rowboat-win32-${arch}`,
|
||||||
setupExe: `Rowboat-win32-${arch}-${pkg.version}-setup.exe`,
|
setupExe: `Rowboat-win32-${arch}-${pkg.version}-setup.exe`,
|
||||||
|
setupIcon: path.join(__dirname, 'icons/icon.ico'),
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -66,7 +67,9 @@ module.exports = {
|
||||||
bin: "rowboat",
|
bin: "rowboat",
|
||||||
description: 'AI coworker with memory',
|
description: 'AI coworker with memory',
|
||||||
maintainer: 'rowboatlabs',
|
maintainer: 'rowboatlabs',
|
||||||
homepage: 'https://rowboatlabs.com'
|
homepage: 'https://rowboatlabs.com',
|
||||||
|
icon: path.join(__dirname, 'icons/icon.png'),
|
||||||
|
mimeType: ['x-scheme-handler/rowboat'],
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
@ -77,7 +80,9 @@ module.exports = {
|
||||||
name: `Rowboat-linux`,
|
name: `Rowboat-linux`,
|
||||||
bin: "rowboat",
|
bin: "rowboat",
|
||||||
description: 'AI coworker with memory',
|
description: 'AI coworker with memory',
|
||||||
homepage: 'https://rowboatlabs.com'
|
homepage: 'https://rowboatlabs.com',
|
||||||
|
icon: path.join(__dirname, 'icons/icon.png'),
|
||||||
|
mimeType: ['x-scheme-handler/rowboat'],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
BIN
apps/x/apps/main/icons/icon.ico
Normal file
BIN
apps/x/apps/main/icons/icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
|
|
@ -10,10 +10,11 @@
|
||||||
"start": "electron .",
|
"start": "electron .",
|
||||||
"build": "rm -rf dist && tsc && node bundle.mjs",
|
"build": "rm -rf dist && tsc && node bundle.mjs",
|
||||||
"package": "electron-forge package",
|
"package": "electron-forge package",
|
||||||
"make": "electron-forge make",
|
"make": "electron-forge make"
|
||||||
"test": "vitest run"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@agentclientprotocol/claude-agent-acp": "^0.39.0",
|
||||||
|
"@agentclientprotocol/codex-acp": "^0.0.44",
|
||||||
"@x/core": "workspace:*",
|
"@x/core": "workspace:*",
|
||||||
"@x/shared": "workspace:*",
|
"@x/shared": "workspace:*",
|
||||||
"chokidar": "^4.0.3",
|
"chokidar": "^4.0.3",
|
||||||
|
|
@ -38,7 +39,6 @@
|
||||||
"@types/electron-squirrel-startup": "^1.0.2",
|
"@types/electron-squirrel-startup": "^1.0.2",
|
||||||
"@types/node": "^25.0.3",
|
"@types/node": "^25.0.3",
|
||||||
"electron": "^39.2.7",
|
"electron": "^39.2.7",
|
||||||
"esbuild": "^0.24.2",
|
"esbuild": "^0.24.2"
|
||||||
"vitest": "^2.1.9"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -63,11 +63,7 @@ export function dispatchDeepLink(url: string): void {
|
||||||
|
|
||||||
interface MeetingNotesAction {
|
interface MeetingNotesAction {
|
||||||
type: "take-meeting-notes" | "join-and-take-meeting-notes";
|
type: "take-meeting-notes" | "join-and-take-meeting-notes";
|
||||||
// eventId is required for join-and-take-meeting-notes (calendar-time fire)
|
eventId: string;
|
||||||
// but optional for take-meeting-notes — mic-detection ad-hoc fires use a
|
|
||||||
// title-only payload when the call isn't on the calendar.
|
|
||||||
eventId?: string;
|
|
||||||
title?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type ParsedAction = MeetingNotesAction;
|
type ParsedAction = MeetingNotesAction;
|
||||||
|
|
@ -80,16 +76,10 @@ function parseAction(url: string): ParsedAction | null {
|
||||||
if (host !== ACTION_HOST) return null;
|
if (host !== ACTION_HOST) return null;
|
||||||
const params = new URLSearchParams(queryIdx >= 0 ? rest.slice(queryIdx + 1) : "");
|
const params = new URLSearchParams(queryIdx >= 0 ? rest.slice(queryIdx + 1) : "");
|
||||||
const type = params.get("type");
|
const type = params.get("type");
|
||||||
const eventId = params.get("eventId") || undefined;
|
if (type === "take-meeting-notes" || type === "join-and-take-meeting-notes") {
|
||||||
const title = params.get("title") || undefined;
|
const eventId = params.get("eventId");
|
||||||
if (type === "join-and-take-meeting-notes") {
|
|
||||||
return eventId ? { type, eventId } : null;
|
return eventId ? { type, eventId } : null;
|
||||||
}
|
}
|
||||||
if (type === "take-meeting-notes") {
|
|
||||||
// Need at least one identifier — eventId (calendar) or title (ad-hoc).
|
|
||||||
if (!eventId && !title) return null;
|
|
||||||
return { type, eventId, title };
|
|
||||||
}
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -98,31 +88,25 @@ async function dispatchAction(url: string): Promise<void> {
|
||||||
if (!parsed) return;
|
if (!parsed) return;
|
||||||
|
|
||||||
const openMeeting = parsed.type === "join-and-take-meeting-notes";
|
const openMeeting = parsed.type === "join-and-take-meeting-notes";
|
||||||
await handleTakeMeetingNotes(parsed.eventId, parsed.title, openMeeting);
|
await handleTakeMeetingNotes(parsed.eventId, openMeeting);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleTakeMeetingNotes(
|
async function handleTakeMeetingNotes(eventId: string, openMeeting: boolean): Promise<void> {
|
||||||
eventId: string | undefined,
|
|
||||||
title: string | undefined,
|
|
||||||
openMeeting: boolean,
|
|
||||||
): Promise<void> {
|
|
||||||
const win = mainWindowRef;
|
const win = mainWindowRef;
|
||||||
if (!win || win.isDestroyed()) return;
|
if (!win || win.isDestroyed()) return;
|
||||||
focusWindow(win);
|
focusWindow(win);
|
||||||
|
|
||||||
let event: unknown = null;
|
const filePath = path.join(WorkDir, "calendar_sync", `${eventId}.json`);
|
||||||
if (eventId) {
|
let event: unknown;
|
||||||
const filePath = path.join(WorkDir, "calendar_sync", `${eventId}.json`);
|
try {
|
||||||
try {
|
const raw = await fs.readFile(filePath, "utf-8");
|
||||||
const raw = await fs.readFile(filePath, "utf-8");
|
event = JSON.parse(raw);
|
||||||
event = JSON.parse(raw);
|
} catch (err) {
|
||||||
} catch (err) {
|
console.error(`[deeplink] take-meeting-notes: failed to read ${filePath}`, err);
|
||||||
console.error(`[deeplink] take-meeting-notes: failed to read ${filePath}`, err);
|
return;
|
||||||
// Fall through with event=null so the renderer can still open an ad-hoc note.
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = { event, openMeeting, title: title ?? null };
|
const payload = { event, openMeeting };
|
||||||
|
|
||||||
if (win.webContents.isLoading()) {
|
if (win.webContents.isLoading()) {
|
||||||
win.webContents.once("did-finish-load", () => {
|
win.webContents.once("did-finish-load", () => {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { ipcMain, BrowserWindow, shell, dialog, systemPreferences, desktopCapturer } from 'electron';
|
import { ipcMain, BrowserWindow, shell, dialog, systemPreferences, desktopCapturer, app } from 'electron';
|
||||||
import { ipc } from '@x/shared';
|
import { ipc } from '@x/shared';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
|
|
@ -8,6 +8,7 @@ import {
|
||||||
listProviders,
|
listProviders,
|
||||||
} from './oauth-handler.js';
|
} from './oauth-handler.js';
|
||||||
import { watcher as watcherCore, workspace } from '@x/core';
|
import { watcher as watcherCore, workspace } from '@x/core';
|
||||||
|
import { WorkDir } from '@x/core/dist/config/config.js';
|
||||||
import { workspace as workspaceShared } from '@x/shared';
|
import { workspace as workspaceShared } from '@x/shared';
|
||||||
import * as mcpCore from '@x/core/dist/mcp/mcp.js';
|
import * as mcpCore from '@x/core/dist/mcp/mcp.js';
|
||||||
import * as runsCore from '@x/core/dist/runs/runs.js';
|
import * as runsCore from '@x/core/dist/runs/runs.js';
|
||||||
|
|
@ -30,6 +31,10 @@ import { listGatewayModels } from '@x/core/dist/models/gateway.js';
|
||||||
import type { IModelConfigRepo } from '@x/core/dist/models/repo.js';
|
import type { IModelConfigRepo } from '@x/core/dist/models/repo.js';
|
||||||
import type { IOAuthRepo } from '@x/core/dist/auth/repo.js';
|
import type { IOAuthRepo } from '@x/core/dist/auth/repo.js';
|
||||||
import { IGranolaConfigRepo } from '@x/core/dist/knowledge/granola/repo.js';
|
import { IGranolaConfigRepo } from '@x/core/dist/knowledge/granola/repo.js';
|
||||||
|
import { ICodeModeConfigRepo } from '@x/core/dist/code-mode/repo.js';
|
||||||
|
import { CodePermissionRegistry } from '@x/core/dist/code-mode/acp/permission-registry.js';
|
||||||
|
import { checkCodeModeAgentStatus } from '@x/core/dist/code-mode/status.js';
|
||||||
|
import { invalidateCopilotInstructionsCache } from '@x/core/dist/application/assistant/instructions.js';
|
||||||
import { triggerSync as triggerGranolaSync } from '@x/core/dist/knowledge/granola/sync.js';
|
import { triggerSync as triggerGranolaSync } from '@x/core/dist/knowledge/granola/sync.js';
|
||||||
import { ISlackConfigRepo } from '@x/core/dist/slack/repo.js';
|
import { ISlackConfigRepo } from '@x/core/dist/slack/repo.js';
|
||||||
import { isOnboardingComplete, markOnboardingComplete } from '@x/core/dist/config/note_creation_config.js';
|
import { isOnboardingComplete, markOnboardingComplete } from '@x/core/dist/config/note_creation_config.js';
|
||||||
|
|
@ -451,6 +456,7 @@ export function setupIpcHandlers() {
|
||||||
return {
|
return {
|
||||||
installationId: getInstallationId(),
|
installationId: getInstallationId(),
|
||||||
apiUrl: API_URL,
|
apiUrl: API_URL,
|
||||||
|
appVersion: app.getVersion(),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
'workspace:getRoot': async () => {
|
'workspace:getRoot': async () => {
|
||||||
|
|
@ -525,12 +531,17 @@ export function setupIpcHandlers() {
|
||||||
return runsCore.createRun(args);
|
return runsCore.createRun(args);
|
||||||
},
|
},
|
||||||
'runs:createMessage': async (_event, args) => {
|
'runs:createMessage': async (_event, args) => {
|
||||||
return { messageId: await runsCore.createMessage(args.runId, args.message, args.voiceInput, args.voiceOutput, args.searchEnabled, args.middlePaneContext) };
|
return { messageId: await runsCore.createMessage(args.runId, args.message, args.voiceInput, args.voiceOutput, args.searchEnabled, args.middlePaneContext, args.codeMode) };
|
||||||
},
|
},
|
||||||
'runs:authorizePermission': async (_event, args) => {
|
'runs:authorizePermission': async (_event, args) => {
|
||||||
await runsCore.authorizePermission(args.runId, args.authorization);
|
await runsCore.authorizePermission(args.runId, args.authorization);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
},
|
},
|
||||||
|
'codeRun:resolvePermission': async (_event, args) => {
|
||||||
|
const registry = container.resolve<CodePermissionRegistry>('codePermissionRegistry');
|
||||||
|
registry.resolve(args.requestId, args.decision);
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
'runs:provideHumanInput': async (_event, args) => {
|
'runs:provideHumanInput': async (_event, args) => {
|
||||||
await runsCore.replyToHumanInputRequest(args.runId, args.reply);
|
await runsCore.replyToHumanInputRequest(args.runId, args.reply);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
|
|
@ -549,6 +560,35 @@ export function setupIpcHandlers() {
|
||||||
await runsCore.deleteRun(args.runId);
|
await runsCore.deleteRun(args.runId);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
},
|
},
|
||||||
|
'runs:downloadLog': async (event, args) => {
|
||||||
|
const runFileName = `${args.runId}.jsonl`;
|
||||||
|
if (path.basename(runFileName) !== runFileName) {
|
||||||
|
return { success: false, error: 'Invalid run id' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourcePath = path.join(WorkDir, 'runs', runFileName);
|
||||||
|
const win = BrowserWindow.fromWebContents(event.sender);
|
||||||
|
const result = await dialog.showSaveDialog(win!, {
|
||||||
|
defaultPath: `${runFileName}.log`,
|
||||||
|
filters: [
|
||||||
|
{ name: 'Chat Log', extensions: ['log'] },
|
||||||
|
{ name: 'JSONL', extensions: ['jsonl'] },
|
||||||
|
{ name: 'All Files', extensions: ['*'] },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.canceled || !result.filePath) {
|
||||||
|
return { success: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fs.copyFile(sourcePath, result.filePath);
|
||||||
|
return { success: true };
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Failed to download chat log';
|
||||||
|
return { success: false, error: message };
|
||||||
|
}
|
||||||
|
},
|
||||||
'models:list': async () => {
|
'models:list': async () => {
|
||||||
if (await isSignedIn()) {
|
if (await isSignedIn()) {
|
||||||
return await listGatewayModels();
|
return await listGatewayModels();
|
||||||
|
|
@ -600,6 +640,20 @@ export function setupIpcHandlers() {
|
||||||
const config = await repo.getConfig();
|
const config = await repo.getConfig();
|
||||||
return { enabled: config.enabled };
|
return { enabled: config.enabled };
|
||||||
},
|
},
|
||||||
|
'codeMode:getConfig': async () => {
|
||||||
|
const repo = container.resolve<ICodeModeConfigRepo>('codeModeConfigRepo');
|
||||||
|
const config = await repo.getConfig();
|
||||||
|
return { enabled: config.enabled, approvalPolicy: config.approvalPolicy };
|
||||||
|
},
|
||||||
|
'codeMode:setConfig': async (_event, args) => {
|
||||||
|
const repo = container.resolve<ICodeModeConfigRepo>('codeModeConfigRepo');
|
||||||
|
await repo.setConfig({ enabled: args.enabled, approvalPolicy: args.approvalPolicy });
|
||||||
|
invalidateCopilotInstructionsCache();
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
'codeMode:checkAgentStatus': async () => {
|
||||||
|
return await checkCodeModeAgentStatus();
|
||||||
|
},
|
||||||
'granola:setConfig': async (_event, args) => {
|
'granola:setConfig': async (_event, args) => {
|
||||||
const repo = container.resolve<IGranolaConfigRepo>('granolaConfigRepo');
|
const repo = container.resolve<IGranolaConfigRepo>('granolaConfigRepo');
|
||||||
await repo.setConfig({ enabled: args.enabled });
|
await repo.setConfig({ enabled: args.enabled });
|
||||||
|
|
|
||||||
|
|
@ -40,23 +40,19 @@ import started from "electron-squirrel-startup";
|
||||||
import { execSync, exec, execFileSync } from "node:child_process";
|
import { execSync, exec, execFileSync } from "node:child_process";
|
||||||
import { promisify } from "node:util";
|
import { promisify } from "node:util";
|
||||||
import { init as initChromeSync } from "@x/core/dist/knowledge/chrome-extension/server/server.js";
|
import { init as initChromeSync } from "@x/core/dist/knowledge/chrome-extension/server/server.js";
|
||||||
import { registerBrowserControlService, registerNotificationService } from "@x/core/dist/di/container.js";
|
import container, { registerBrowserControlService, registerNotificationService } from "@x/core/dist/di/container.js";
|
||||||
|
import type { CodeModeManager } from "@x/core/dist/code-mode/acp/manager.js";
|
||||||
import { browserViewManager, BROWSER_PARTITION } from "./browser/view.js";
|
import { browserViewManager, BROWSER_PARTITION } from "./browser/view.js";
|
||||||
import { setupBrowserEventForwarding } from "./browser/ipc.js";
|
import { setupBrowserEventForwarding } from "./browser/ipc.js";
|
||||||
import { ElectronBrowserControlService } from "./browser/control-service.js";
|
import { ElectronBrowserControlService } from "./browser/control-service.js";
|
||||||
import { ElectronNotificationService } from "./notification/electron-notification-service.js";
|
import { ElectronNotificationService } from "./notification/electron-notification-service.js";
|
||||||
import {
|
|
||||||
createPlatformDetector,
|
|
||||||
MeetingDetectService,
|
|
||||||
Suppression,
|
|
||||||
} from "./meeting-detect/index.js";
|
|
||||||
import { MeetingToastWindow } from "./meeting-detect/toast-window.js";
|
|
||||||
import {
|
import {
|
||||||
DEEP_LINK_SCHEME,
|
DEEP_LINK_SCHEME,
|
||||||
dispatchUrl,
|
dispatchUrl,
|
||||||
extractDeepLinkFromArgv,
|
extractDeepLinkFromArgv,
|
||||||
setMainWindowForDeepLinks,
|
setMainWindowForDeepLinks,
|
||||||
} from "./deeplink.js";
|
} from "./deeplink.js";
|
||||||
|
import { disconnectGoogleIfScopesStale } from "./oauth-handler.js";
|
||||||
|
|
||||||
const execAsync = promisify(exec);
|
const execAsync = promisify(exec);
|
||||||
|
|
||||||
|
|
@ -225,6 +221,7 @@ function createWindow() {
|
||||||
backgroundColor: "#252525", // Prevent white flash (matches dark mode)
|
backgroundColor: "#252525", // Prevent white flash (matches dark mode)
|
||||||
titleBarStyle: "hiddenInset",
|
titleBarStyle: "hiddenInset",
|
||||||
trafficLightPosition: { x: 12, y: 12 },
|
trafficLightPosition: { x: 12, y: 12 },
|
||||||
|
icon: process.platform !== "darwin" ? path.join(__dirname, "../../icons/icon.png") : undefined,
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
// IMPORTANT: keep Node out of renderer
|
// IMPORTANT: keep Node out of renderer
|
||||||
nodeIntegration: false,
|
nodeIntegration: false,
|
||||||
|
|
@ -243,29 +240,6 @@ function createWindow() {
|
||||||
setMainWindowForDeepLinks(win);
|
setMainWindowForDeepLinks(win);
|
||||||
win.on("closed", () => setMainWindowForDeepLinks(null));
|
win.on("closed", () => setMainWindowForDeepLinks(null));
|
||||||
|
|
||||||
// Dev-only: Ctrl+Shift+T fires a fake meeting-detect toast so we can
|
|
||||||
// iterate on the toast UI without joining a real Meet. Scoped to the
|
|
||||||
// main window's input events so it can't collide with browser/OS chords.
|
|
||||||
if (!app.isPackaged) {
|
|
||||||
win.webContents.on("before-input-event", (event, input) => {
|
|
||||||
const isToggle =
|
|
||||||
input.type === "keyDown" &&
|
|
||||||
input.control && input.shift && !input.alt && !input.meta &&
|
|
||||||
input.key.toLowerCase() === "t";
|
|
||||||
if (!isToggle) return;
|
|
||||||
event.preventDefault();
|
|
||||||
new MeetingToastWindow().show({
|
|
||||||
title: "You are in a meeting",
|
|
||||||
subtitle: "Detected on Google Meet",
|
|
||||||
actionLabel: "Start taking notes",
|
|
||||||
actionLink:
|
|
||||||
"rowboat://action?type=take-meeting-notes&title=" +
|
|
||||||
encodeURIComponent("Meeting Notes - Meet"),
|
|
||||||
});
|
|
||||||
console.log("[MeetingDetect] dev toast triggered (Ctrl+Shift+T)");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show window when content is ready to prevent blank screen
|
// Show window when content is ready to prevent blank screen
|
||||||
win.once("ready-to-show", () => {
|
win.once("ready-to-show", () => {
|
||||||
win.maximize();
|
win.maximize();
|
||||||
|
|
@ -341,8 +315,7 @@ app.whenReady().then(async () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
registerBrowserControlService(new ElectronBrowserControlService());
|
registerBrowserControlService(new ElectronBrowserControlService());
|
||||||
const notificationService = new ElectronNotificationService();
|
registerNotificationService(new ElectronNotificationService());
|
||||||
registerNotificationService(notificationService);
|
|
||||||
|
|
||||||
setupIpcHandlers();
|
setupIpcHandlers();
|
||||||
setupBrowserEventForwarding();
|
setupBrowserEventForwarding();
|
||||||
|
|
@ -381,6 +354,11 @@ app.whenReady().then(async () => {
|
||||||
registerConsumer(backgroundTaskEventConsumer);
|
registerConsumer(backgroundTaskEventConsumer);
|
||||||
initEventProcessor();
|
initEventProcessor();
|
||||||
|
|
||||||
|
// If the stored Google grant predates a scope change (only old scopes),
|
||||||
|
// disconnect it now so the user re-connects with the current scopes before
|
||||||
|
// any Google sync runs against the stale grant.
|
||||||
|
await disconnectGoogleIfScopesStale();
|
||||||
|
|
||||||
// start gmail sync
|
// start gmail sync
|
||||||
initGmailSync();
|
initGmailSync();
|
||||||
|
|
||||||
|
|
@ -414,30 +392,6 @@ app.whenReady().then(async () => {
|
||||||
// start calendar meeting notification service (fires 1-minute warnings)
|
// start calendar meeting notification service (fires 1-minute warnings)
|
||||||
initCalendarNotifications();
|
initCalendarNotifications();
|
||||||
|
|
||||||
// start meeting-detect service (mic-in-use detection -> popup asking if user wants notes)
|
|
||||||
//
|
|
||||||
// Popup style — flip this one constant to switch the meeting-detect prompt
|
|
||||||
// between the custom Notion-style top-center toast and the native OS
|
|
||||||
// notification. Doesn't affect the separate calendar 1-min warnings.
|
|
||||||
// false (default) → custom toast
|
|
||||||
// true → native OS notification
|
|
||||||
const USE_NATIVE_NOTIFICATION_FOR_MEETING_DETECT = false;
|
|
||||||
|
|
||||||
const meetingDetector = createPlatformDetector();
|
|
||||||
if (meetingDetector) {
|
|
||||||
const meetingDetectService = new MeetingDetectService({
|
|
||||||
detector: meetingDetector,
|
|
||||||
notifier: notificationService,
|
|
||||||
suppression: new Suppression(),
|
|
||||||
toast: USE_NATIVE_NOTIFICATION_FOR_MEETING_DETECT ? null : undefined,
|
|
||||||
});
|
|
||||||
meetingDetectService.start().catch((err) => {
|
|
||||||
console.error("[MeetingDetect] failed to start:", err);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
console.log("[MeetingDetect] no detector for this platform; skipping");
|
|
||||||
}
|
|
||||||
|
|
||||||
// start chrome extension sync server
|
// start chrome extension sync server
|
||||||
initChromeSync();
|
initChromeSync();
|
||||||
|
|
||||||
|
|
@ -464,6 +418,12 @@ app.on("before-quit", () => {
|
||||||
stopWorkspaceWatcher();
|
stopWorkspaceWatcher();
|
||||||
stopRunsWatcher();
|
stopRunsWatcher();
|
||||||
stopServicesWatcher();
|
stopServicesWatcher();
|
||||||
|
// Tear down any live ACP coding-agent adapter processes so they don't outlive the app.
|
||||||
|
try {
|
||||||
|
container.resolve<CodeModeManager>('codeModeManager').disposeAll();
|
||||||
|
} catch {
|
||||||
|
// nothing live to dispose
|
||||||
|
}
|
||||||
shutdownLocalSites().catch((error) => {
|
shutdownLocalSites().catch((error) => {
|
||||||
console.error('[LocalSites] Failed to shut down cleanly:', error);
|
console.error('[LocalSites] Failed to shut down cleanly:', error);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,89 +0,0 @@
|
||||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
||||||
import path from "node:path";
|
|
||||||
import fs from "node:fs/promises";
|
|
||||||
import os from "node:os";
|
|
||||||
import { buildAdHocTitle, shortPlatformLabel } from "./ad-hoc-title.js";
|
|
||||||
|
|
||||||
let tmpRoot: string;
|
|
||||||
const NOW = new Date(2026, 4, 15, 14, 0, 0); // 2026-05-15 14:00 local
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "rb-adhoc-title-"));
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
await fs.rm(tmpRoot, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
async function writeNote(day: string, filename: string): Promise<void> {
|
|
||||||
const dir = path.join(tmpRoot, day);
|
|
||||||
await fs.mkdir(dir, { recursive: true });
|
|
||||||
await fs.writeFile(path.join(dir, filename), "stub", "utf-8");
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("buildAdHocTitle", () => {
|
|
||||||
it("returns the bare title for the first occurrence of the day", async () => {
|
|
||||||
const title = await buildAdHocTitle({ platformLabel: "Zoom", now: NOW, root: tmpRoot });
|
|
||||||
expect(title).toBe("Meeting Notes - Zoom");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("appends #2 when one already exists", async () => {
|
|
||||||
await writeNote("2026-05-15", "Meeting_Notes_-_Zoom.md");
|
|
||||||
const title = await buildAdHocTitle({ platformLabel: "Zoom", now: NOW, root: tmpRoot });
|
|
||||||
expect(title).toBe("Meeting Notes - Zoom #2");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("increments past #2 (#3, #4, ...)", async () => {
|
|
||||||
await writeNote("2026-05-15", "Meeting_Notes_-_Zoom.md");
|
|
||||||
await writeNote("2026-05-15", "Meeting_Notes_-_Zoom_#2.md");
|
|
||||||
await writeNote("2026-05-15", "Meeting_Notes_-_Zoom_#3.md");
|
|
||||||
const title = await buildAdHocTitle({ platformLabel: "Zoom", now: NOW, root: tmpRoot });
|
|
||||||
expect(title).toBe("Meeting Notes - Zoom #4");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("doesn't cross-count platforms (Meet vs Zoom stay distinct)", async () => {
|
|
||||||
await writeNote("2026-05-15", "Meeting_Notes_-_Zoom.md");
|
|
||||||
const title = await buildAdHocTitle({ platformLabel: "Meet", now: NOW, root: tmpRoot });
|
|
||||||
expect(title).toBe("Meeting Notes - Meet");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("resets the counter on a different day", async () => {
|
|
||||||
await writeNote("2026-05-14", "Meeting_Notes_-_Zoom.md");
|
|
||||||
const title = await buildAdHocTitle({ platformLabel: "Zoom", now: NOW, root: tmpRoot });
|
|
||||||
expect(title).toBe("Meeting Notes - Zoom");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("ignores non-meeting notes in the same folder", async () => {
|
|
||||||
await writeNote("2026-05-15", "standup.md");
|
|
||||||
await writeNote("2026-05-15", "random_note.md");
|
|
||||||
const title = await buildAdHocTitle({ platformLabel: "Zoom", now: NOW, root: tmpRoot });
|
|
||||||
expect(title).toBe("Meeting Notes - Zoom");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("matches slug-variant filenames (different separators)", async () => {
|
|
||||||
// Whatever the renderer's slugifier does, normalize() should match.
|
|
||||||
await writeNote("2026-05-15", "Meeting Notes - Zoom.md");
|
|
||||||
await writeNote("2026-05-15", "Meeting-Notes--Zoom.md"); // hypothetical alt slug
|
|
||||||
const title = await buildAdHocTitle({ platformLabel: "Zoom", now: NOW, root: tmpRoot });
|
|
||||||
expect(title).toBe("Meeting Notes - Zoom #3");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("shortPlatformLabel", () => {
|
|
||||||
it("maps browser platforms to short labels", () => {
|
|
||||||
expect(shortPlatformLabel({ browserPlatform: "google-meet", kind: "browser" })).toBe("Meet");
|
|
||||||
expect(shortPlatformLabel({ browserPlatform: "zoom-web", kind: "browser" })).toBe("Zoom");
|
|
||||||
expect(shortPlatformLabel({ browserPlatform: "teams-web", kind: "browser" })).toBe("Teams");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("maps native kinds to short labels", () => {
|
|
||||||
expect(shortPlatformLabel({ kind: "zoom" })).toBe("Zoom");
|
|
||||||
expect(shortPlatformLabel({ kind: "teams" })).toBe("Teams");
|
|
||||||
expect(shortPlatformLabel({ kind: "discord" })).toBe("Discord");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns null for unmatched browser / unknown", () => {
|
|
||||||
expect(shortPlatformLabel({ kind: "browser" })).toBeNull();
|
|
||||||
expect(shortPlatformLabel({ kind: "unknown" })).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,101 +0,0 @@
|
||||||
import path from "node:path";
|
|
||||||
import fs from "node:fs/promises";
|
|
||||||
import { WorkDir } from "@x/core/dist/config/config.js";
|
|
||||||
|
|
||||||
// Ad-hoc meeting titles: "Meeting Notes - <Platform>" with a per-day counter
|
|
||||||
// suffix when there's already one for the same platform on the same day.
|
|
||||||
//
|
|
||||||
// first Zoom today → "Meeting Notes - Zoom"
|
|
||||||
// second Zoom today → "Meeting Notes - Zoom #2"
|
|
||||||
// first Zoom tomorrow → "Meeting Notes - Zoom" (fresh folder, fresh count)
|
|
||||||
|
|
||||||
const MEETINGS_ROOT = path.join(WorkDir, "knowledge", "Meetings", "rowboat");
|
|
||||||
const TITLE_PREFIX = "Meeting Notes - ";
|
|
||||||
|
|
||||||
export interface AdHocTitleOptions {
|
|
||||||
platformLabel: string;
|
|
||||||
now?: Date;
|
|
||||||
// Override for tests; defaults to the user's real meetings folder.
|
|
||||||
root?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function buildAdHocTitle(opts: AdHocTitleOptions): Promise<string> {
|
|
||||||
const platform = opts.platformLabel;
|
|
||||||
const base = `${TITLE_PREFIX}${platform}`;
|
|
||||||
|
|
||||||
const now = opts.now ?? new Date();
|
|
||||||
const dayFolder = path.join(opts.root ?? MEETINGS_ROOT, formatDay(now));
|
|
||||||
|
|
||||||
const existing = await countMatching(dayFolder, base);
|
|
||||||
if (existing === 0) return base;
|
|
||||||
return `${base} #${existing + 1}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDay(d: Date): string {
|
|
||||||
// YYYY-MM-DD in local time — matches the existing knowledge/Meetings/rowboat layout.
|
|
||||||
const y = d.getFullYear();
|
|
||||||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
|
||||||
const day = String(d.getDate()).padStart(2, "0");
|
|
||||||
return `${y}-${m}-${day}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function countMatching(dir: string, baseTitle: string): Promise<number> {
|
|
||||||
let entries: string[];
|
|
||||||
try {
|
|
||||||
entries = await fs.readdir(dir);
|
|
||||||
} catch {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
const needle = normalize(baseTitle);
|
|
||||||
let count = 0;
|
|
||||||
for (const name of entries) {
|
|
||||||
if (!name.endsWith(".md")) continue;
|
|
||||||
const stem = name.slice(0, -3); // strip .md
|
|
||||||
if (normalize(stem).startsWith(needle)) count++;
|
|
||||||
}
|
|
||||||
return count;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Normalize a title or filename to alphanumerics-only-lowercase so we can
|
|
||||||
* compare across slugification rules:
|
|
||||||
* "Meeting Notes - Zoom" → "meetingnoteszoom"
|
|
||||||
* "Meeting_Notes_-_Zoom.md" → "meetingnoteszoom" (after .md strip)
|
|
||||||
* "Meeting Notes - Zoom #2" → "meetingnoteszoom2"
|
|
||||||
*
|
|
||||||
* Anchoring with startsWith() then catches both the bare title and any
|
|
||||||
* counter-suffixed variant, without colliding across platforms ("Meet"
|
|
||||||
* vs "Zoom" stay distinct because the platform name appears after the
|
|
||||||
* common "meetingnotes" prefix).
|
|
||||||
*/
|
|
||||||
function normalize(s: string): string {
|
|
||||||
return s.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Map our internal platform/kind names to user-facing short labels.
|
|
||||||
// Re-exported so service.ts can produce both the popup body label and the
|
|
||||||
// note title from the same source of truth.
|
|
||||||
export function shortPlatformLabel(input: {
|
|
||||||
browserPlatform?: "google-meet" | "zoom-web" | "teams-web" | "slack-huddle" | "webex-web";
|
|
||||||
kind: "zoom" | "teams" | "slack" | "discord" | "webex" | "browser" | "unknown";
|
|
||||||
}): string | null {
|
|
||||||
if (input.browserPlatform) {
|
|
||||||
switch (input.browserPlatform) {
|
|
||||||
case "google-meet": return "Meet";
|
|
||||||
case "zoom-web": return "Zoom";
|
|
||||||
case "teams-web": return "Teams";
|
|
||||||
case "slack-huddle": return "Slack";
|
|
||||||
case "webex-web": return "Webex";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
switch (input.kind) {
|
|
||||||
case "zoom": return "Zoom";
|
|
||||||
case "teams": return "Teams";
|
|
||||||
case "slack": return "Slack";
|
|
||||||
case "discord": return "Discord";
|
|
||||||
case "webex": return "Webex";
|
|
||||||
case "browser":
|
|
||||||
case "unknown":
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import { matchTitleOrUrl } from "./browser-match.js";
|
|
||||||
|
|
||||||
describe("matchTitleOrUrl", () => {
|
|
||||||
it("matches Google Meet by URL", () => {
|
|
||||||
const m = matchTitleOrUrl("Meet — Standup", "https://meet.google.com/abc-defg-hij");
|
|
||||||
expect(m?.platform).toBe("google-meet");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("matches Google Meet by window title alone (Windows/Mac no-URL case)", () => {
|
|
||||||
const m = matchTitleOrUrl("Meet - Daily Standup - Google Chrome", undefined);
|
|
||||||
expect(m?.platform).toBe("google-meet");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("matches Meet with em-dash variant (locale-dependent title)", () => {
|
|
||||||
const m = matchTitleOrUrl("Meet — Daily Standup", undefined);
|
|
||||||
expect(m?.platform).toBe("google-meet");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("matches Zoom web client", () => {
|
|
||||||
const m = matchTitleOrUrl("Zoom Meeting", "https://us02web.zoom.us/j/123456789");
|
|
||||||
expect(m?.platform).toBe("zoom-web");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("matches Teams web", () => {
|
|
||||||
const m = matchTitleOrUrl("Meeting | Microsoft Teams", "https://teams.microsoft.com/_#/calendarv2");
|
|
||||||
expect(m?.platform).toBe("teams-web");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("ignores random YouTube tab", () => {
|
|
||||||
const m = matchTitleOrUrl("Mock Interview - YouTube", "https://www.youtube.com/watch?v=abc");
|
|
||||||
expect(m).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns null for empty input", () => {
|
|
||||||
expect(matchTitleOrUrl(undefined, undefined)).toBeNull();
|
|
||||||
expect(matchTitleOrUrl("", "")).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("is case-insensitive", () => {
|
|
||||||
const m = matchTitleOrUrl("ZOOM MEETING", "https://ZOOM.US/J/999");
|
|
||||||
expect(m?.platform).toBe("zoom-web");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,66 +0,0 @@
|
||||||
import { getWindowSnapshot } from "./foreground-window.js";
|
|
||||||
|
|
||||||
export type BrowserMeetingPlatform = "google-meet" | "zoom-web" | "teams-web" | "slack-huddle" | "webex-web";
|
|
||||||
|
|
||||||
export interface BrowserMeetingMatch {
|
|
||||||
platform: BrowserMeetingPlatform;
|
|
||||||
// Best-effort URL or tab title we matched on — useful for the popup copy.
|
|
||||||
hint: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TitleRule {
|
|
||||||
platform: BrowserMeetingPlatform;
|
|
||||||
// Substrings checked against the (case-insensitive) window title / URL.
|
|
||||||
needles: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Substrings we look for in the foreground window title (or URL when we
|
|
||||||
// have it). On Chrome/Edge/Firefox the page title is embedded in the window
|
|
||||||
// title, which is the most reliable cross-platform signal.
|
|
||||||
// Meet page title: "Meet - Daily Standup" → matches "meet -"
|
|
||||||
// Zoom web client: "Zoom Meeting" → matches "zoom meeting"
|
|
||||||
// Teams web: "<topic> | Microsoft Teams" → matches "microsoft teams"
|
|
||||||
const RULES: TitleRule[] = [
|
|
||||||
{ platform: "google-meet", needles: ["meet.google.com", "google meet", "meet -", "meet —", "meet |"] },
|
|
||||||
{ platform: "zoom-web", needles: ["zoom.us/j/", "zoom.us/wc/", "zoom meeting"] },
|
|
||||||
{ platform: "teams-web", needles: ["teams.microsoft.com", "microsoft teams"] },
|
|
||||||
{ platform: "slack-huddle", needles: ["app.slack.com", "slack huddle"] },
|
|
||||||
{ platform: "webex-web", needles: ["webex.com/meet", "webex.com/wbxmjs", "webex meeting"] },
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Look at the foreground window. If it's a browser and the title matches a
|
|
||||||
* known meeting URL/platform, return a match. Returns null otherwise.
|
|
||||||
*
|
|
||||||
* Caller is expected to only invoke this when the detector classified the
|
|
||||||
* mic-holder as `kind: "browser"`. That keeps active-win calls cheap — we
|
|
||||||
* only ask the OS when there's a reason to ask.
|
|
||||||
*/
|
|
||||||
export async function matchBrowserMeeting(executable?: string): Promise<BrowserMeetingMatch | null> {
|
|
||||||
const snap = await getWindowSnapshot(executable);
|
|
||||||
if (!snap) return null;
|
|
||||||
// Scan ALL known window titles — on Windows tasklist returns every window,
|
|
||||||
// so even a backgrounded Meet tab still matches while Chrome holds the mic.
|
|
||||||
for (const title of snap.titles) {
|
|
||||||
const m = matchTitleOrUrl(title, undefined);
|
|
||||||
if (m) return m;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Pure matcher — exposed for tests; no OS calls. */
|
|
||||||
export function matchTitleOrUrl(title: string | undefined, url: string | undefined): BrowserMeetingMatch | null {
|
|
||||||
// active-win returns `url` on macOS for Chromium-family + Safari (Accessibility-perm gated).
|
|
||||||
// On Windows, only `title` is reliable. Match against both.
|
|
||||||
const haystack = `${url ?? ""}\n${title ?? ""}`.toLowerCase();
|
|
||||||
if (!haystack.trim()) return null;
|
|
||||||
|
|
||||||
for (const rule of RULES) {
|
|
||||||
for (const needle of rule.needles) {
|
|
||||||
if (haystack.includes(needle)) {
|
|
||||||
return { platform: rule.platform, hint: url || title || "" };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
@ -1,111 +0,0 @@
|
||||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
||||||
import path from "node:path";
|
|
||||||
import fs from "node:fs/promises";
|
|
||||||
import os from "node:os";
|
|
||||||
import { correlateFromDir } from "./calendar-correlate.js";
|
|
||||||
|
|
||||||
let tmpDir: string;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "rb-meeting-detect-"));
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
async function writeEvent(name: string, body: unknown): Promise<void> {
|
|
||||||
await fs.writeFile(path.join(tmpDir, `${name}.json`), JSON.stringify(body), "utf-8");
|
|
||||||
}
|
|
||||||
|
|
||||||
function evt(opts: {
|
|
||||||
id: string;
|
|
||||||
summary: string;
|
|
||||||
startMinutes: number; // minutes from `anchor`
|
|
||||||
endMinutes: number;
|
|
||||||
cancelled?: boolean;
|
|
||||||
declined?: boolean;
|
|
||||||
hangoutLink?: string;
|
|
||||||
}): unknown {
|
|
||||||
const anchor = new Date("2026-05-15T10:00:00Z").getTime();
|
|
||||||
return {
|
|
||||||
id: opts.id,
|
|
||||||
summary: opts.summary,
|
|
||||||
status: opts.cancelled ? "cancelled" : "confirmed",
|
|
||||||
start: { dateTime: new Date(anchor + opts.startMinutes * 60_000).toISOString() },
|
|
||||||
end: { dateTime: new Date(anchor + opts.endMinutes * 60_000).toISOString() },
|
|
||||||
attendees: [
|
|
||||||
{ self: true, responseStatus: opts.declined ? "declined" : "accepted" },
|
|
||||||
{ email: "alice@example.com", displayName: "Alice" },
|
|
||||||
],
|
|
||||||
hangoutLink: opts.hangoutLink,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("correlateFromDir", () => {
|
|
||||||
const NOW = new Date("2026-05-15T10:30:00Z");
|
|
||||||
|
|
||||||
it("returns null when the directory does not exist", async () => {
|
|
||||||
const result = await correlateFromDir(path.join(tmpDir, "does-not-exist"), NOW);
|
|
||||||
expect(result).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns null when no events overlap", async () => {
|
|
||||||
await writeEvent("e1", evt({ id: "e1", summary: "Morning", startMinutes: -120, endMinutes: -60 }));
|
|
||||||
const result = await correlateFromDir(tmpDir, NOW);
|
|
||||||
expect(result).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("matches an event in progress", async () => {
|
|
||||||
await writeEvent("e1", evt({
|
|
||||||
id: "e1",
|
|
||||||
summary: "Q2 Planning",
|
|
||||||
startMinutes: 25, // 10:25, NOW=10:30 → in progress
|
|
||||||
endMinutes: 55,
|
|
||||||
hangoutLink: "https://meet.google.com/abc",
|
|
||||||
}));
|
|
||||||
const result = await correlateFromDir(tmpDir, NOW);
|
|
||||||
expect(result?.eventId).toBe("e1");
|
|
||||||
expect(result?.summary).toBe("Q2 Planning");
|
|
||||||
expect(result?.meetingUrl).toBe("https://meet.google.com/abc");
|
|
||||||
expect(result?.attendees).toHaveLength(1); // self filtered
|
|
||||||
expect(result?.attendees[0].email).toBe("alice@example.com");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("matches an event starting within pre-roll", async () => {
|
|
||||||
await writeEvent("e1", evt({
|
|
||||||
id: "e1",
|
|
||||||
summary: "Upcoming",
|
|
||||||
startMinutes: 31, // NOW=10:30, event at 10:31 → 1 min away, within 2-min pre-roll
|
|
||||||
endMinutes: 60,
|
|
||||||
}));
|
|
||||||
const result = await correlateFromDir(tmpDir, NOW);
|
|
||||||
expect(result?.eventId).toBe("e1");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("ignores cancelled events", async () => {
|
|
||||||
await writeEvent("e1", evt({ id: "e1", summary: "Dead", startMinutes: 25, endMinutes: 55, cancelled: true }));
|
|
||||||
const result = await correlateFromDir(tmpDir, NOW);
|
|
||||||
expect(result).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("ignores events the user declined", async () => {
|
|
||||||
await writeEvent("e1", evt({ id: "e1", summary: "Nope", startMinutes: 25, endMinutes: 55, declined: true }));
|
|
||||||
const result = await correlateFromDir(tmpDir, NOW);
|
|
||||||
expect(result).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("picks the closest event when multiple overlap", async () => {
|
|
||||||
await writeEvent("far", evt({ id: "far", summary: "Far", startMinutes: -10, endMinutes: 35 }));
|
|
||||||
await writeEvent("near", evt({ id: "near", summary: "Near", startMinutes: 29, endMinutes: 59 }));
|
|
||||||
const result = await correlateFromDir(tmpDir, NOW);
|
|
||||||
expect(result?.eventId).toBe("near");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("ignores sync_state.json", async () => {
|
|
||||||
await writeEvent("sync_state", { lastSync: "whatever" });
|
|
||||||
await writeEvent("e1", evt({ id: "e1", summary: "Real", startMinutes: 25, endMinutes: 55 }));
|
|
||||||
const result = await correlateFromDir(tmpDir, NOW);
|
|
||||||
expect(result?.eventId).toBe("e1");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,123 +0,0 @@
|
||||||
import path from "node:path";
|
|
||||||
import fs from "node:fs/promises";
|
|
||||||
import { WorkDir } from "@x/core/dist/config/config.js";
|
|
||||||
|
|
||||||
// Match a detection event against the user's synced calendar. The detector
|
|
||||||
// fires when the mic flips on; if there's a calendar event currently in
|
|
||||||
// progress (or about to start / just ended), we attach its metadata so the
|
|
||||||
// popup can show the right title and the deeplink can target the right note.
|
|
||||||
|
|
||||||
const CALENDAR_SYNC_DIR = path.join(WorkDir, "calendar_sync");
|
|
||||||
|
|
||||||
// Pre-roll: someone joining 2 min early should still match the upcoming event.
|
|
||||||
const PRE_ROLL_MS = 2 * 60 * 1000;
|
|
||||||
// Post-roll: someone joining 2 min late (or a meeting that ran long and the
|
|
||||||
// next-event window already started) should still match.
|
|
||||||
const POST_ROLL_MS = 2 * 60 * 1000;
|
|
||||||
|
|
||||||
interface CalendarEventFile {
|
|
||||||
id?: string;
|
|
||||||
summary?: string;
|
|
||||||
status?: string;
|
|
||||||
start?: { dateTime?: string };
|
|
||||||
end?: { dateTime?: string };
|
|
||||||
attendees?: Array<{ email?: string; displayName?: string; self?: boolean; responseStatus?: string }>;
|
|
||||||
hangoutLink?: string;
|
|
||||||
conferenceData?: { entryPoints?: Array<{ entryPointType?: string; uri?: string }> };
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CorrelatedEvent {
|
|
||||||
eventId: string;
|
|
||||||
summary: string;
|
|
||||||
startMs: number;
|
|
||||||
endMs: number;
|
|
||||||
attendees: Array<{ email?: string; displayName?: string }>;
|
|
||||||
meetingUrl?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find a calendar event whose [start - PRE_ROLL, end + POST_ROLL] window
|
|
||||||
* contains `now`. Returns the closest match (smallest |now - start|) when
|
|
||||||
* multiple events overlap (back-to-back meetings).
|
|
||||||
*/
|
|
||||||
export async function correlateNow(now: Date = new Date()): Promise<CorrelatedEvent | null> {
|
|
||||||
return correlateFromDir(CALENDAR_SYNC_DIR, now);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Exposed for tests — accepts an arbitrary directory of calendar JSON files. */
|
|
||||||
export async function correlateFromDir(dir: string, now: Date): Promise<CorrelatedEvent | null> {
|
|
||||||
let entries: string[];
|
|
||||||
try {
|
|
||||||
entries = await fs.readdir(dir);
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const nowMs = now.getTime();
|
|
||||||
let best: { event: CorrelatedEvent; distance: number } | null = null;
|
|
||||||
|
|
||||||
for (const name of entries) {
|
|
||||||
if (!name.endsWith(".json")) continue;
|
|
||||||
if (name === "sync_state.json" || name.startsWith("sync_state")) continue;
|
|
||||||
|
|
||||||
let raw: string;
|
|
||||||
try {
|
|
||||||
raw = await fs.readFile(path.join(dir, name), "utf-8");
|
|
||||||
} catch {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let event: CalendarEventFile;
|
|
||||||
try {
|
|
||||||
event = JSON.parse(raw);
|
|
||||||
} catch {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.status === "cancelled") continue;
|
|
||||||
if (isDeclinedBySelf(event)) continue;
|
|
||||||
|
|
||||||
const startStr = event.start?.dateTime;
|
|
||||||
const endStr = event.end?.dateTime;
|
|
||||||
if (!startStr || !endStr) continue;
|
|
||||||
|
|
||||||
const startMs = Date.parse(startStr);
|
|
||||||
const endMs = Date.parse(endStr);
|
|
||||||
if (!Number.isFinite(startMs) || !Number.isFinite(endMs)) continue;
|
|
||||||
|
|
||||||
// Skip events outside the active window.
|
|
||||||
if (nowMs < startMs - PRE_ROLL_MS) continue;
|
|
||||||
if (nowMs > endMs + POST_ROLL_MS) continue;
|
|
||||||
|
|
||||||
const eventId = event.id || name.replace(/\.json$/, "");
|
|
||||||
const correlated: CorrelatedEvent = {
|
|
||||||
eventId,
|
|
||||||
summary: event.summary?.trim() || "Untitled meeting",
|
|
||||||
startMs,
|
|
||||||
endMs,
|
|
||||||
attendees: (event.attendees || [])
|
|
||||||
.filter((a) => !a.self)
|
|
||||||
.map((a) => ({ email: a.email, displayName: a.displayName })),
|
|
||||||
meetingUrl: extractMeetingUrl(event),
|
|
||||||
};
|
|
||||||
|
|
||||||
const distance = Math.abs(nowMs - startMs);
|
|
||||||
if (!best || distance < best.distance) {
|
|
||||||
best = { event: correlated, distance };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return best?.event ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isDeclinedBySelf(event: CalendarEventFile): boolean {
|
|
||||||
if (!event.attendees) return false;
|
|
||||||
const self = event.attendees.find((a) => a.self);
|
|
||||||
return self?.responseStatus === "declined";
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractMeetingUrl(event: CalendarEventFile): string | undefined {
|
|
||||||
if (event.hangoutLink) return event.hangoutLink;
|
|
||||||
const eps = event.conferenceData?.entryPoints || [];
|
|
||||||
const video = eps.find((e) => e.entryPointType === "video");
|
|
||||||
return video?.uri;
|
|
||||||
}
|
|
||||||
|
|
@ -1,134 +0,0 @@
|
||||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
||||||
import { MeetingDetector, type MeetingActiveEvent, type MeetingClearedEvent } from "./detector.js";
|
|
||||||
import type { MicProbe, MicUser } from "./types.js";
|
|
||||||
|
|
||||||
class FakeProbe implements MicProbe {
|
|
||||||
private next: MicUser[] = [];
|
|
||||||
setNext(users: MicUser[]): void { this.next = users; }
|
|
||||||
async probe(): Promise<MicUser[]> { return this.next; }
|
|
||||||
}
|
|
||||||
|
|
||||||
function collect(detector: MeetingDetector) {
|
|
||||||
const active: MeetingActiveEvent[] = [];
|
|
||||||
const cleared: MeetingClearedEvent[] = [];
|
|
||||||
detector.on("meeting-active", (e) => active.push(e));
|
|
||||||
detector.on("meeting-cleared", (e) => cleared.push(e));
|
|
||||||
return { active, cleared };
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("MeetingDetector", () => {
|
|
||||||
let probe: FakeProbe;
|
|
||||||
let detector: MeetingDetector;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
probe = new FakeProbe();
|
|
||||||
// tickMs is irrelevant — we drive ticks manually.
|
|
||||||
detector = new MeetingDetector(probe, 999_999);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("emits meeting-active once when a Zoom-like exe appears", async () => {
|
|
||||||
const { active } = collect(detector);
|
|
||||||
|
|
||||||
probe.setNext([{ executable: "C:\\Program Files\\Zoom\\bin\\Zoom.exe" }]);
|
|
||||||
await detector.tick();
|
|
||||||
|
|
||||||
expect(active).toHaveLength(1);
|
|
||||||
expect(active[0].kind).toBe("zoom");
|
|
||||||
expect(active[0].executable).toContain("Zoom.exe");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not re-emit while the same exe keeps appearing", async () => {
|
|
||||||
const { active } = collect(detector);
|
|
||||||
const user = { executable: "/Applications/zoom.us.app/Contents/MacOS/zoom.us", pid: 4711 };
|
|
||||||
|
|
||||||
probe.setNext([user]);
|
|
||||||
await detector.tick();
|
|
||||||
await detector.tick();
|
|
||||||
await detector.tick();
|
|
||||||
|
|
||||||
expect(active).toHaveLength(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("emits meeting-cleared when the exe disappears", async () => {
|
|
||||||
const { active, cleared } = collect(detector);
|
|
||||||
const user = { executable: "zoom.us", pid: 4711 };
|
|
||||||
|
|
||||||
probe.setNext([user]);
|
|
||||||
await detector.tick();
|
|
||||||
|
|
||||||
probe.setNext([]);
|
|
||||||
await detector.tick();
|
|
||||||
|
|
||||||
expect(active).toHaveLength(1);
|
|
||||||
expect(cleared).toHaveLength(1);
|
|
||||||
expect(cleared[0].sessionKey).toBe(active[0].sessionKey);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("ignores unknown executables (Voice Memos, OBS, etc.)", async () => {
|
|
||||||
const { active, cleared } = collect(detector);
|
|
||||||
|
|
||||||
probe.setNext([{ executable: "Voice Memos", pid: 999 }]);
|
|
||||||
await detector.tick();
|
|
||||||
|
|
||||||
probe.setNext([]);
|
|
||||||
await detector.tick();
|
|
||||||
|
|
||||||
expect(active).toHaveLength(0);
|
|
||||||
expect(cleared).toHaveLength(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("classifies a browser as kind=browser (for downstream tab-title check)", async () => {
|
|
||||||
const { active } = collect(detector);
|
|
||||||
|
|
||||||
probe.setNext([{ executable: "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", pid: 5050 }]);
|
|
||||||
await detector.tick();
|
|
||||||
|
|
||||||
expect(active).toHaveLength(1);
|
|
||||||
expect(active[0].kind).toBe("browser");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("treats a relaunched app (new pid) as a new session on macOS", async () => {
|
|
||||||
const { active, cleared } = collect(detector);
|
|
||||||
|
|
||||||
probe.setNext([{ executable: "zoom.us", pid: 100 }]);
|
|
||||||
await detector.tick();
|
|
||||||
|
|
||||||
probe.setNext([]); // app closed
|
|
||||||
await detector.tick();
|
|
||||||
|
|
||||||
probe.setNext([{ executable: "zoom.us", pid: 200 }]); // re-opened
|
|
||||||
await detector.tick();
|
|
||||||
|
|
||||||
expect(active).toHaveLength(2);
|
|
||||||
expect(cleared).toHaveLength(1);
|
|
||||||
expect(active[0].sessionKey).not.toBe(active[1].sessionKey);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles multiple concurrent meeting apps independently", async () => {
|
|
||||||
const { active, cleared } = collect(detector);
|
|
||||||
|
|
||||||
probe.setNext([
|
|
||||||
{ executable: "zoom.us", pid: 100 },
|
|
||||||
{ executable: "Microsoft Teams", pid: 200 },
|
|
||||||
]);
|
|
||||||
await detector.tick();
|
|
||||||
|
|
||||||
probe.setNext([{ executable: "Microsoft Teams", pid: 200 }]);
|
|
||||||
await detector.tick();
|
|
||||||
|
|
||||||
expect(active).toHaveLength(2);
|
|
||||||
expect(active.map((e) => e.kind).sort()).toEqual(["teams", "zoom"]);
|
|
||||||
expect(cleared).toHaveLength(1);
|
|
||||||
expect(cleared[0].sessionKey).toContain("zoom.us");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("recovers without crashing when the probe throws", async () => {
|
|
||||||
const flaky: MicProbe = { probe: vi.fn().mockRejectedValueOnce(new Error("boom")) };
|
|
||||||
const d = new MeetingDetector(flaky, 999_999);
|
|
||||||
// tick() awaits probe.probe() so a rejection bubbles — start() catches it. Verify start() doesn't throw.
|
|
||||||
d.start();
|
|
||||||
await new Promise((r) => setTimeout(r, 10));
|
|
||||||
d.stop();
|
|
||||||
expect(flaky.probe).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,107 +0,0 @@
|
||||||
import { EventEmitter } from "node:events";
|
|
||||||
import { classifyExecutable, type MeetingAppKind } from "./meeting-apps.js";
|
|
||||||
import type { MicProbe, MicUser } from "./types.js";
|
|
||||||
|
|
||||||
const DEFAULT_TICK_MS = 3_000;
|
|
||||||
|
|
||||||
export interface MeetingActiveEvent {
|
|
||||||
executable: string;
|
|
||||||
pid?: number;
|
|
||||||
kind: MeetingAppKind;
|
|
||||||
// Stable key for dedup — exe path (plus pid on mac so a Zoom relaunch counts as a new session).
|
|
||||||
sessionKey: string;
|
|
||||||
startedAt: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MeetingClearedEvent {
|
|
||||||
sessionKey: string;
|
|
||||||
endedAt: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Polls a platform-specific MicProbe and emits when a whitelisted meeting app
|
|
||||||
* starts / stops holding the mic. One emit per distinct session — a session
|
|
||||||
* lasts as long as the same exe (+pid on macOS) keeps appearing in probe
|
|
||||||
* results across ticks.
|
|
||||||
*
|
|
||||||
* Pure logic; UI/notification wiring lives in the service layer. Probe is
|
|
||||||
* injected so this is testable without a real OS.
|
|
||||||
*/
|
|
||||||
export class MeetingDetector extends EventEmitter {
|
|
||||||
private readonly probe: MicProbe;
|
|
||||||
private readonly tickMs: number;
|
|
||||||
private active = new Map<string, MeetingActiveEvent>();
|
|
||||||
private timer: NodeJS.Timeout | null = null;
|
|
||||||
private running = false;
|
|
||||||
|
|
||||||
constructor(probe: MicProbe, tickMs: number = DEFAULT_TICK_MS) {
|
|
||||||
super();
|
|
||||||
this.probe = probe;
|
|
||||||
this.tickMs = tickMs;
|
|
||||||
}
|
|
||||||
|
|
||||||
start(): void {
|
|
||||||
if (this.timer) return;
|
|
||||||
const loop = async () => {
|
|
||||||
if (!this.running) return;
|
|
||||||
try {
|
|
||||||
await this.tick();
|
|
||||||
} catch (err) {
|
|
||||||
console.error("[MeetingDetect] tick failed:", err);
|
|
||||||
}
|
|
||||||
if (this.running) this.timer = setTimeout(loop, this.tickMs);
|
|
||||||
};
|
|
||||||
this.running = true;
|
|
||||||
// Run first tick immediately; subsequent ticks scheduled by the loop.
|
|
||||||
this.timer = setTimeout(loop, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
stop(): void {
|
|
||||||
this.running = false;
|
|
||||||
if (this.timer) {
|
|
||||||
clearTimeout(this.timer);
|
|
||||||
this.timer = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Exposed for tests — drive a single probe-and-diff cycle. */
|
|
||||||
async tick(): Promise<void> {
|
|
||||||
const users = await this.probe.probe();
|
|
||||||
const seenKeys = new Set<string>();
|
|
||||||
const now = new Date();
|
|
||||||
|
|
||||||
for (const user of users) {
|
|
||||||
const kind = classifyExecutable(user.executable);
|
|
||||||
if (kind === "unknown") continue;
|
|
||||||
|
|
||||||
const key = sessionKey(user);
|
|
||||||
seenKeys.add(key);
|
|
||||||
|
|
||||||
if (!this.active.has(key)) {
|
|
||||||
const event: MeetingActiveEvent = {
|
|
||||||
executable: user.executable,
|
|
||||||
pid: user.pid,
|
|
||||||
kind,
|
|
||||||
sessionKey: key,
|
|
||||||
startedAt: now,
|
|
||||||
};
|
|
||||||
this.active.set(key, event);
|
|
||||||
this.emit("meeting-active", event);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [key, event] of this.active) {
|
|
||||||
if (seenKeys.has(key)) continue;
|
|
||||||
this.active.delete(key);
|
|
||||||
const cleared: MeetingClearedEvent = { sessionKey: key, endedAt: now };
|
|
||||||
this.emit("meeting-cleared", cleared);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function sessionKey(user: MicUser): string {
|
|
||||||
// On macOS we include pid so an app relaunch counts as a new session.
|
|
||||||
// On Windows there's no pid; the exe path alone is sufficient because
|
|
||||||
// Windows can't tell us *which instance* of an exe is holding the mic.
|
|
||||||
return user.pid !== undefined ? `${user.executable}#${user.pid}` : user.executable;
|
|
||||||
}
|
|
||||||
|
|
@ -1,180 +0,0 @@
|
||||||
import { execFile } from "node:child_process";
|
|
||||||
import { promisify } from "node:util";
|
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
|
||||||
|
|
||||||
export interface WindowSnapshot {
|
|
||||||
// Window titles we know about. Implementations may return one (foreground)
|
|
||||||
// or many (all titles for a process). browser-match scans the whole list,
|
|
||||||
// so we don't need to identify which is foreground.
|
|
||||||
titles: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Best-effort look at currently-open window titles (and, on macOS, tab URLs)
|
|
||||||
* for a given executable. On Windows: `tasklist /v /fi "imagename eq <exe>"` —
|
|
||||||
* fast because it skips every system process. On macOS: AppleScript that
|
|
||||||
* enumerates every browser tab (URL + title) for Chromium-family browsers and
|
|
||||||
* Safari, falling back to the frontmost window title for everything else.
|
|
||||||
*
|
|
||||||
* Pass the basename of the exe (e.g. "chrome.exe") or the macOS process name.
|
|
||||||
* Returns null on failure; an empty title list means "process is running but no
|
|
||||||
* window/tab title is available."
|
|
||||||
*/
|
|
||||||
export async function getWindowSnapshot(executable?: string): Promise<WindowSnapshot | null> {
|
|
||||||
if (process.platform === "win32") return getWindowSnapshotWindows(executable);
|
|
||||||
if (process.platform === "darwin") return getWindowSnapshotMacOS(executable);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getWindowSnapshotWindows(executable?: string): Promise<WindowSnapshot | null> {
|
|
||||||
// Reduce to a basename — full paths can't be passed to tasklist's
|
|
||||||
// imagename filter, and the filter wants e.g. "chrome.exe", not the path.
|
|
||||||
const imageName = executable ? executable.replace(/^.*[\\/]/, "") : "";
|
|
||||||
const args = ["/v", "/fo", "csv", "/nh"];
|
|
||||||
if (imageName) args.push("/fi", `imagename eq ${imageName}`);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { stdout } = await execFileAsync(
|
|
||||||
"tasklist.exe",
|
|
||||||
args,
|
|
||||||
{ timeout: 10_000, windowsHide: true, maxBuffer: 4 * 1024 * 1024 },
|
|
||||||
);
|
|
||||||
const titles: string[] = [];
|
|
||||||
for (const line of stdout.split(/\r?\n/)) {
|
|
||||||
if (!line) continue;
|
|
||||||
const fields = parseCsvLine(line);
|
|
||||||
if (fields.length === 0) continue;
|
|
||||||
const title = fields[fields.length - 1];
|
|
||||||
if (!title || title === "N/A") continue;
|
|
||||||
titles.push(title);
|
|
||||||
}
|
|
||||||
return { titles };
|
|
||||||
} catch (err) {
|
|
||||||
console.error("[MeetingDetect] window-snapshot (windows) failed:", err);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseCsvLine(line: string): string[] {
|
|
||||||
// tasklist /fo csv quotes every field and doesn't embed quotes within fields,
|
|
||||||
// so a simple comma-split between quoted segments works.
|
|
||||||
const out: string[] = [];
|
|
||||||
const re = /"([^"]*)"/g;
|
|
||||||
let m: RegExpExecArray | null;
|
|
||||||
while ((m = re.exec(line)) !== null) out.push(m[1]);
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Chromium-family browsers share Chrome's AppleScript dictionary (each tab
|
|
||||||
// exposes `URL` and `title`). Safari uses `name` for the tab title. Firefox and
|
|
||||||
// anything else expose no tab scripting, so they fall back to the frontmost
|
|
||||||
// window title. Keyed by a substring of the pmset process name.
|
|
||||||
const CHROMIUM_APPS: Record<string, string> = {
|
|
||||||
"google chrome": "Google Chrome",
|
|
||||||
"brave browser": "Brave Browser",
|
|
||||||
"microsoft edge": "Microsoft Edge",
|
|
||||||
"vivaldi": "Vivaldi",
|
|
||||||
"opera": "Opera",
|
|
||||||
"arc": "Arc",
|
|
||||||
};
|
|
||||||
|
|
||||||
function browserApp(executable?: string): { app: string; titleProp: "title" | "name" } | null {
|
|
||||||
const e = (executable ?? "").toLowerCase();
|
|
||||||
for (const [needle, app] of Object.entries(CHROMIUM_APPS)) {
|
|
||||||
if (e.includes(needle)) return { app, titleProp: "title" };
|
|
||||||
}
|
|
||||||
if (e.includes("safari")) return { app: "Safari", titleProp: "name" };
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Walk every window/tab of a browser and emit "<url>\n<title>" per tab. We need
|
|
||||||
// ALL tabs, not just the frontmost: the user is often looking at another app
|
|
||||||
// (e.g. taking notes) while the Meet/Zoom/Teams tab sits in the background.
|
|
||||||
function tabEnumScript(app: string, titleProp: "title" | "name"): string {
|
|
||||||
return [
|
|
||||||
`tell application "${app}"`,
|
|
||||||
` set _out to ""`,
|
|
||||||
` repeat with _w in windows`,
|
|
||||||
` repeat with _t in tabs of _w`,
|
|
||||||
` set _out to _out & (URL of _t) & linefeed & (${titleProp} of _t) & linefeed`,
|
|
||||||
` end repeat`,
|
|
||||||
` end repeat`,
|
|
||||||
` return _out`,
|
|
||||||
`end tell`,
|
|
||||||
].join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Frontmost window title — needs Accessibility permission. Last-resort signal
|
|
||||||
// for Firefox/unknown browsers (no tab scripting) or when tab enumeration is
|
|
||||||
// blocked.
|
|
||||||
const FRONT_WINDOW_SCRIPT = `
|
|
||||||
tell application "System Events"
|
|
||||||
set frontApp to first application process whose frontmost is true
|
|
||||||
set appName to name of frontApp
|
|
||||||
try
|
|
||||||
set winTitle to name of front window of frontApp
|
|
||||||
on error
|
|
||||||
set winTitle to ""
|
|
||||||
end try
|
|
||||||
return appName & "\\n" & winTitle
|
|
||||||
end tell
|
|
||||||
`.trim();
|
|
||||||
|
|
||||||
function isPermissionError(err: unknown): boolean {
|
|
||||||
// osascript denied by TCC: Automation (-1743) or Accessibility (-1719).
|
|
||||||
const msg = err instanceof Error ? `${err.message} ${(err as { stderr?: string }).stderr ?? ""}` : String(err);
|
|
||||||
return msg.includes("-1743") || msg.includes("-1719") || /not authoriz|not allowed/i.test(msg);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getWindowSnapshotMacOS(executable?: string): Promise<WindowSnapshot | null> {
|
|
||||||
const browser = browserApp(executable);
|
|
||||||
if (browser) {
|
|
||||||
const tabs = await enumerateBrowserTabs(browser.app, browser.titleProp);
|
|
||||||
if (tabs && tabs.length > 0) return { titles: tabs };
|
|
||||||
// Empty/blocked → fall through to the frontmost-window title below.
|
|
||||||
}
|
|
||||||
return frontmostWindowTitle();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function enumerateBrowserTabs(app: string, titleProp: "title" | "name"): Promise<string[] | null> {
|
|
||||||
try {
|
|
||||||
const { stdout } = await execFileAsync("/usr/bin/osascript", ["-e", tabEnumScript(app, titleProp)], {
|
|
||||||
timeout: 5_000,
|
|
||||||
maxBuffer: 4 * 1024 * 1024,
|
|
||||||
});
|
|
||||||
// Each tab contributed a URL line and a title line; both feed matchTitleOrUrl.
|
|
||||||
return stdout.split("\n").map((l) => l.trim()).filter(Boolean);
|
|
||||||
} catch (err) {
|
|
||||||
if (isPermissionError(err)) {
|
|
||||||
console.warn(
|
|
||||||
`[MeetingDetect] cannot read ${app} tabs — grant Automation permission in ` +
|
|
||||||
`System Settings → Privacy & Security → Automation (Rowboat → ${app}). Falling back to window title.`,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
console.error(`[MeetingDetect] tab enumeration (${app}) failed:`, err);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function frontmostWindowTitle(): Promise<WindowSnapshot | null> {
|
|
||||||
try {
|
|
||||||
const { stdout } = await execFileAsync("/usr/bin/osascript", ["-e", FRONT_WINDOW_SCRIPT], {
|
|
||||||
timeout: 5_000,
|
|
||||||
});
|
|
||||||
const [, ...titleParts] = stdout.trim().split("\n");
|
|
||||||
const title = titleParts.join("\n");
|
|
||||||
return { titles: title ? [title] : [] };
|
|
||||||
} catch (err) {
|
|
||||||
if (isPermissionError(err)) {
|
|
||||||
console.warn(
|
|
||||||
"[MeetingDetect] cannot read the frontmost window title — grant Accessibility " +
|
|
||||||
"permission in System Settings → Privacy & Security → Accessibility (Rowboat).",
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
console.error("[MeetingDetect] window-snapshot (macOS) failed:", err);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
import { MeetingDetector } from "./detector.js";
|
|
||||||
import { WindowsMicProbe } from "./probe-windows.js";
|
|
||||||
import { MacOsMicProbe } from "./probe-macos.js";
|
|
||||||
import type { MicProbe } from "./types.js";
|
|
||||||
|
|
||||||
export { MeetingDetector } from "./detector.js";
|
|
||||||
export type { MeetingActiveEvent, MeetingClearedEvent } from "./detector.js";
|
|
||||||
export { classifyExecutable, isMeetingApp, isBrowser } from "./meeting-apps.js";
|
|
||||||
export type { MeetingAppKind } from "./meeting-apps.js";
|
|
||||||
export type { MicProbe, MicUser } from "./types.js";
|
|
||||||
export { Suppression, InMemorySuppressionStore } from "./suppression.js";
|
|
||||||
export type { SuppressionStore } from "./suppression.js";
|
|
||||||
export { MeetingDetectService, buildPopup } from "./service.js";
|
|
||||||
export type { MeetingDetectServiceOptions } from "./service.js";
|
|
||||||
|
|
||||||
export function createPlatformDetector(): MeetingDetector | null {
|
|
||||||
const probe = createPlatformProbe();
|
|
||||||
if (!probe) return null;
|
|
||||||
return new MeetingDetector(probe);
|
|
||||||
}
|
|
||||||
|
|
||||||
function createPlatformProbe(): MicProbe | null {
|
|
||||||
if (process.platform === "win32") return new WindowsMicProbe();
|
|
||||||
if (process.platform === "darwin") return new MacOsMicProbe();
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import { classifyExecutable } from "./meeting-apps.js";
|
|
||||||
|
|
||||||
describe("classifyExecutable", () => {
|
|
||||||
it("classifies Zoom on both platforms", () => {
|
|
||||||
expect(classifyExecutable("Zoom.exe")).toBe("zoom"); // Windows
|
|
||||||
expect(classifyExecutable("zoom.us")).toBe("zoom"); // macOS pmset name
|
|
||||||
});
|
|
||||||
|
|
||||||
it("classifies the new Teams client by its macOS/Windows process name", () => {
|
|
||||||
expect(classifyExecutable("MSTeams")).toBe("teams"); // macOS pmset name
|
|
||||||
expect(classifyExecutable("ms-teams.exe")).toBe("teams"); // Windows
|
|
||||||
expect(classifyExecutable("Microsoft Teams")).toBe("teams"); // classic
|
|
||||||
});
|
|
||||||
|
|
||||||
it("classifies browsers as the browser kind", () => {
|
|
||||||
expect(classifyExecutable("Google Chrome")).toBe("browser");
|
|
||||||
expect(classifyExecutable("Safari")).toBe("browser");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns unknown for unrelated processes", () => {
|
|
||||||
expect(classifyExecutable("Finder")).toBe("unknown");
|
|
||||||
expect(classifyExecutable("WindowServer")).toBe("unknown");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,51 +0,0 @@
|
||||||
// Whitelist of executables / bundle IDs we treat as "the user is in a meeting"
|
|
||||||
// when they're holding the microphone. Native meeting apps map 1:1; browsers
|
|
||||||
// map to "maybe — check the foreground tab title before firing."
|
|
||||||
|
|
||||||
export type MeetingAppKind = "zoom" | "teams" | "slack" | "discord" | "webex" | "browser" | "unknown";
|
|
||||||
|
|
||||||
interface AppRule {
|
|
||||||
kind: MeetingAppKind;
|
|
||||||
// Case-insensitive substring match against the executable path / basename
|
|
||||||
// (Windows: full exe path from registry; macOS: process name from pmset).
|
|
||||||
match: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const RULES: AppRule[] = [
|
|
||||||
{ kind: "zoom", match: ["zoom.exe", "zoom.us", "cpthost.exe"] },
|
|
||||||
// "msteams" covers the current macOS/Windows process name (the new Teams ships
|
|
||||||
// as MSTeams); the others cover the classic client and the AUMID/bundle forms.
|
|
||||||
{ kind: "teams", match: ["ms-teams.exe", "teams.exe", "msteams", "microsoft teams"] },
|
|
||||||
{ kind: "slack", match: ["slack.exe", "slack helper", "slack"] },
|
|
||||||
{ kind: "discord", match: ["discord.exe", "discord"] },
|
|
||||||
{ kind: "webex", match: ["webex.exe", "ciscowebex", "webexmta"] },
|
|
||||||
// Browsers — kind "browser" means we still need a tab-title check before firing.
|
|
||||||
{ kind: "browser", match: [
|
|
||||||
"chrome.exe", "google chrome",
|
|
||||||
"msedge.exe", "microsoft edge",
|
|
||||||
"firefox.exe", "firefox",
|
|
||||||
"arc.exe", "arc",
|
|
||||||
"brave.exe", "brave browser",
|
|
||||||
"safari",
|
|
||||||
"vivaldi.exe", "vivaldi",
|
|
||||||
"opera.exe", "opera",
|
|
||||||
]},
|
|
||||||
];
|
|
||||||
|
|
||||||
export function classifyExecutable(executable: string): MeetingAppKind {
|
|
||||||
const haystack = executable.toLowerCase();
|
|
||||||
for (const rule of RULES) {
|
|
||||||
for (const needle of rule.match) {
|
|
||||||
if (haystack.includes(needle)) return rule.kind;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "unknown";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isMeetingApp(executable: string): boolean {
|
|
||||||
return classifyExecutable(executable) !== "unknown";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isBrowser(executable: string): boolean {
|
|
||||||
return classifyExecutable(executable) === "browser";
|
|
||||||
}
|
|
||||||
|
|
@ -1,47 +0,0 @@
|
||||||
import { execFile } from "node:child_process";
|
|
||||||
import { promisify } from "node:util";
|
|
||||||
import type { MicProbe, MicUser } from "./types.js";
|
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
|
||||||
|
|
||||||
// macOS doesn't expose a public "who is using the mic right now" API. Two
|
|
||||||
// pragmatic signals we can read from a shell without a native helper:
|
|
||||||
//
|
|
||||||
// 1. `pmset -g assertions` — apps in a video call almost always hold a
|
|
||||||
// PreventUserIdleDisplaySleep wake-lock to keep the screen on. Strong
|
|
||||||
// proxy for "active call." False positives: video playback (YouTube,
|
|
||||||
// Netflix) — Phase 2's tab-title check filters those out for browsers.
|
|
||||||
//
|
|
||||||
// 2. `lsof | grep coreaudiod` — clients connected to coreaudiod. Noisy and
|
|
||||||
// doesn't always include the mic user, so we prefer pmset as primary.
|
|
||||||
//
|
|
||||||
// Output format from `pmset -g assertions`:
|
|
||||||
// pid 4711(zoom.us): [0x00000ff...] 00:23:14 PreventUserIdleDisplaySleep named: "..."
|
|
||||||
const ASSERTION_LINE = /^\s*pid\s+(\d+)\((.+?)\):\s+\[[^\]]+\]\s+\S+\s+(PreventUserIdle\w+)/;
|
|
||||||
|
|
||||||
export class MacOsMicProbe implements MicProbe {
|
|
||||||
async probe(): Promise<MicUser[]> {
|
|
||||||
let stdout: string;
|
|
||||||
try {
|
|
||||||
const result = await execFileAsync("/usr/bin/pmset", ["-g", "assertions"], {
|
|
||||||
timeout: 10_000,
|
|
||||||
});
|
|
||||||
stdout = result.stdout;
|
|
||||||
} catch (err) {
|
|
||||||
console.error("[MeetingDetect] macOS probe failed:", err);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const seen = new Map<number, MicUser>();
|
|
||||||
for (const line of stdout.split("\n")) {
|
|
||||||
const m = ASSERTION_LINE.exec(line);
|
|
||||||
if (!m) continue;
|
|
||||||
const pid = Number(m[1]);
|
|
||||||
const command = m[2].trim();
|
|
||||||
if (!Number.isFinite(pid)) continue;
|
|
||||||
if (seen.has(pid)) continue;
|
|
||||||
seen.set(pid, { executable: command, pid });
|
|
||||||
}
|
|
||||||
return Array.from(seen.values());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,85 +0,0 @@
|
||||||
import { execFile } from "node:child_process";
|
|
||||||
import { promisify } from "node:util";
|
|
||||||
import type { MicProbe, MicUser } from "./types.js";
|
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
|
||||||
|
|
||||||
// Windows records every mic-using app under CapabilityAccessManager. Each app
|
|
||||||
// subkey has LastUsedTimeStart and LastUsedTimeStop (FILETIME, int64). When
|
|
||||||
// Start > Stop, the app is currently holding the mic. Subkey names under
|
|
||||||
// NonPackaged are the executable path with `\` replaced by `#`.
|
|
||||||
//
|
|
||||||
// We shell out to PowerShell (single Get-ChildItem walk) rather than pulling
|
|
||||||
// in a native registry binding — far simpler to ship inside Electron and the
|
|
||||||
// poll cadence is 3s, so spawn cost is irrelevant.
|
|
||||||
const POWERSHELL_SCRIPT = `
|
|
||||||
$paths = @(
|
|
||||||
'HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\CapabilityAccessManager\\ConsentStore\\microphone\\NonPackaged',
|
|
||||||
'HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\CapabilityAccessManager\\ConsentStore\\microphone'
|
|
||||||
)
|
|
||||||
$out = New-Object System.Collections.ArrayList
|
|
||||||
foreach ($p in $paths) {
|
|
||||||
if (-not (Test-Path $p)) { continue }
|
|
||||||
Get-ChildItem -Path $p -ErrorAction SilentlyContinue | ForEach-Object {
|
|
||||||
$props = Get-ItemProperty -Path $_.PSPath -ErrorAction SilentlyContinue
|
|
||||||
if ($null -eq $props) { return }
|
|
||||||
$start = $props.LastUsedTimeStart
|
|
||||||
$stop = $props.LastUsedTimeStop
|
|
||||||
if ($null -ne $start -and $null -ne $stop -and $start -gt $stop) {
|
|
||||||
[void]$out.Add([PSCustomObject]@{ Name = $_.PSChildName })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$out | ConvertTo-Json -Compress
|
|
||||||
`.trim();
|
|
||||||
|
|
||||||
interface RawRow {
|
|
||||||
Name?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function decodeNonPackagedName(name: string): string {
|
|
||||||
// NonPackaged subkeys: "C:#Program Files#Zoom#bin#Zoom.exe" → "C:\Program Files\Zoom\bin\Zoom.exe"
|
|
||||||
// Packaged subkeys are AUMIDs (e.g. "Microsoft.Teams_..._mscorlib") — leave as-is.
|
|
||||||
if (name.includes("#") && !name.includes("\\")) {
|
|
||||||
return name.replace(/#/g, "\\");
|
|
||||||
}
|
|
||||||
return name;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class WindowsMicProbe implements MicProbe {
|
|
||||||
async probe(): Promise<MicUser[]> {
|
|
||||||
let stdout: string;
|
|
||||||
try {
|
|
||||||
const result = await execFileAsync(
|
|
||||||
"powershell.exe",
|
|
||||||
["-NoProfile", "-NonInteractive", "-Command", POWERSHELL_SCRIPT],
|
|
||||||
{ timeout: 10_000, windowsHide: true },
|
|
||||||
);
|
|
||||||
stdout = result.stdout.trim();
|
|
||||||
} catch (err) {
|
|
||||||
console.error("[MeetingDetect] Windows probe failed:", err);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
if (!stdout) return [];
|
|
||||||
|
|
||||||
let parsed: RawRow[] | RawRow;
|
|
||||||
try {
|
|
||||||
parsed = JSON.parse(stdout);
|
|
||||||
} catch (err) {
|
|
||||||
console.error("[MeetingDetect] Windows probe parse failed:", err);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
// ConvertTo-Json emits a single object (not an array) when the list has one item.
|
|
||||||
const rows: RawRow[] = Array.isArray(parsed) ? parsed : [parsed];
|
|
||||||
const seen = new Set<string>();
|
|
||||||
const out: MicUser[] = [];
|
|
||||||
for (const row of rows) {
|
|
||||||
if (!row || typeof row.Name !== "string") continue;
|
|
||||||
const exe = decodeNonPackagedName(row.Name);
|
|
||||||
if (seen.has(exe)) continue;
|
|
||||||
seen.add(exe);
|
|
||||||
out.push({ executable: exe });
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,206 +0,0 @@
|
||||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
||||||
import type { INotificationService, NotifyInput } from "@x/core/dist/application/notification/service.js";
|
|
||||||
import { MeetingDetector } from "./detector.js";
|
|
||||||
import type { MicProbe, MicUser } from "./types.js";
|
|
||||||
import { MeetingDetectService, buildPopup } from "./service.js";
|
|
||||||
import { Suppression, InMemorySuppressionStore } from "./suppression.js";
|
|
||||||
import type { BrowserMeetingMatch } from "./browser-match.js";
|
|
||||||
import type { CorrelatedEvent } from "./calendar-correlate.js";
|
|
||||||
|
|
||||||
class FakeProbe implements MicProbe {
|
|
||||||
next: MicUser[] = [];
|
|
||||||
async probe(): Promise<MicUser[]> { return this.next; }
|
|
||||||
}
|
|
||||||
|
|
||||||
class FakeNotifier implements INotificationService {
|
|
||||||
sent: NotifyInput[] = [];
|
|
||||||
isSupported(): boolean { return true; }
|
|
||||||
notify(input: NotifyInput): void { this.sent.push(input); }
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("buildPopup", () => {
|
|
||||||
it("uses the calendar event summary when correlated", () => {
|
|
||||||
const corr: CorrelatedEvent = {
|
|
||||||
eventId: "abc123",
|
|
||||||
summary: "Q2 Planning",
|
|
||||||
startMs: 0, endMs: 0,
|
|
||||||
attendees: [],
|
|
||||||
};
|
|
||||||
const popup = buildPopup("zoom", null, corr);
|
|
||||||
expect(popup?.notify.message).toContain("Q2 Planning");
|
|
||||||
expect(popup?.notify.link).toContain("eventId=abc123");
|
|
||||||
expect(popup?.notify.link).toContain("take-meeting-notes");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls back to ad-hoc copy when no calendar match", () => {
|
|
||||||
const popup = buildPopup("zoom", null, null);
|
|
||||||
expect(popup?.notify.title).toBe("You are in a meeting");
|
|
||||||
expect(popup?.notify.link).toContain("title=");
|
|
||||||
expect(popup?.notify.link).not.toContain("eventId=");
|
|
||||||
// Default ad-hoc title (no precomputed counter) is "Meeting Notes - Zoom".
|
|
||||||
expect(decodeURIComponent(popup!.notify.link.split("title=")[1])).toBe("Meeting Notes - Zoom");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses the precomputed ad-hoc title when provided (counter case)", () => {
|
|
||||||
const popup = buildPopup("zoom", null, null, "Meeting Notes - Zoom #2");
|
|
||||||
expect(decodeURIComponent(popup!.notify.link.split("title=")[1])).toBe("Meeting Notes - Zoom #2");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses browser match platform label when kind=browser", () => {
|
|
||||||
const m: BrowserMeetingMatch = { platform: "google-meet", hint: "https://meet.google.com/abc" };
|
|
||||||
const popup = buildPopup("browser", m, null);
|
|
||||||
expect(popup?.notify.message).toContain("Google Meet");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns null for unknown app without browser match (defensive)", () => {
|
|
||||||
expect(buildPopup("unknown", null, null)).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("MeetingDetectService end-to-end", () => {
|
|
||||||
let probe: FakeProbe;
|
|
||||||
let detector: MeetingDetector;
|
|
||||||
let notifier: FakeNotifier;
|
|
||||||
let suppression: Suppression;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
probe = new FakeProbe();
|
|
||||||
detector = new MeetingDetector(probe, 999_999);
|
|
||||||
notifier = new FakeNotifier();
|
|
||||||
suppression = new Suppression(new InMemorySuppressionStore());
|
|
||||||
});
|
|
||||||
|
|
||||||
it("fires notification when a zoom call is detected, with calendar context", async () => {
|
|
||||||
const correlated: CorrelatedEvent = {
|
|
||||||
eventId: "evt-1",
|
|
||||||
summary: "Standup",
|
|
||||||
startMs: 0, endMs: 0,
|
|
||||||
attendees: [],
|
|
||||||
};
|
|
||||||
const service = new MeetingDetectService({
|
|
||||||
detector,
|
|
||||||
notifier,
|
|
||||||
suppression,
|
|
||||||
matchBrowser: async () => null,
|
|
||||||
correlate: async () => correlated,
|
|
||||||
toast: null,
|
|
||||||
});
|
|
||||||
await service.start();
|
|
||||||
|
|
||||||
probe.next = [{ executable: "zoom.us", pid: 100 }];
|
|
||||||
await detector.tick();
|
|
||||||
await service.settle();
|
|
||||||
|
|
||||||
expect(notifier.sent).toHaveLength(1);
|
|
||||||
expect(notifier.sent[0].title).toBe("Take notes for this meeting?");
|
|
||||||
expect(notifier.sent[0].message).toContain("Standup");
|
|
||||||
expect(notifier.sent[0].link).toContain("eventId=evt-1");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does NOT fire for a browser if the foreground tab is not a meeting page", async () => {
|
|
||||||
const service = new MeetingDetectService({
|
|
||||||
detector,
|
|
||||||
notifier,
|
|
||||||
suppression,
|
|
||||||
matchBrowser: async () => null, // browser foreground = not a meeting
|
|
||||||
correlate: async () => null,
|
|
||||||
toast: null,
|
|
||||||
});
|
|
||||||
await service.start();
|
|
||||||
|
|
||||||
probe.next = [{ executable: "Google Chrome", pid: 200 }];
|
|
||||||
await detector.tick();
|
|
||||||
await service.settle();
|
|
||||||
|
|
||||||
expect(notifier.sent).toHaveLength(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("FIRES for a browser when the foreground tab IS a meeting page", async () => {
|
|
||||||
const service = new MeetingDetectService({
|
|
||||||
detector,
|
|
||||||
notifier,
|
|
||||||
suppression,
|
|
||||||
matchBrowser: async () => ({ platform: "google-meet", hint: "https://meet.google.com/x" }),
|
|
||||||
correlate: async () => null,
|
|
||||||
toast: null,
|
|
||||||
});
|
|
||||||
await service.start();
|
|
||||||
|
|
||||||
probe.next = [{ executable: "Google Chrome", pid: 200 }];
|
|
||||||
await detector.tick();
|
|
||||||
await service.settle();
|
|
||||||
|
|
||||||
expect(notifier.sent).toHaveLength(1);
|
|
||||||
expect(notifier.sent[0].message).toContain("Google Meet");
|
|
||||||
expect(notifier.sent[0].link).toContain("title="); // ad-hoc, no eventId
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not re-fire on consecutive ticks for the same session", async () => {
|
|
||||||
const service = new MeetingDetectService({
|
|
||||||
detector,
|
|
||||||
notifier,
|
|
||||||
suppression,
|
|
||||||
matchBrowser: async () => null,
|
|
||||||
correlate: async () => null,
|
|
||||||
toast: null,
|
|
||||||
});
|
|
||||||
await service.start();
|
|
||||||
|
|
||||||
probe.next = [{ executable: "zoom.us", pid: 100 }];
|
|
||||||
await detector.tick();
|
|
||||||
await detector.tick();
|
|
||||||
await detector.tick();
|
|
||||||
await service.settle();
|
|
||||||
|
|
||||||
expect(notifier.sent).toHaveLength(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses the toast renderer when provided instead of the native notifier", async () => {
|
|
||||||
const calls: Array<{ title: string; subtitle: string; actionLink: string }> = [];
|
|
||||||
const toast = {
|
|
||||||
show(p: { title: string; subtitle: string; actionLabel: string; actionLink: string }) {
|
|
||||||
calls.push({ title: p.title, subtitle: p.subtitle, actionLink: p.actionLink });
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const service = new MeetingDetectService({
|
|
||||||
detector,
|
|
||||||
notifier,
|
|
||||||
suppression,
|
|
||||||
matchBrowser: async () => null,
|
|
||||||
correlate: async () => null,
|
|
||||||
toast,
|
|
||||||
});
|
|
||||||
await service.start();
|
|
||||||
|
|
||||||
probe.next = [{ executable: "zoom.us", pid: 100 }];
|
|
||||||
await detector.tick();
|
|
||||||
await service.settle();
|
|
||||||
|
|
||||||
expect(notifier.sent).toHaveLength(0);
|
|
||||||
expect(calls).toHaveLength(1);
|
|
||||||
expect(calls[0].title).toBe("You are in a meeting");
|
|
||||||
expect(calls[0].subtitle).toContain("Zoom");
|
|
||||||
expect(calls[0].actionLink).toContain("take-meeting-notes");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("respects per-app mute", async () => {
|
|
||||||
await suppression.init();
|
|
||||||
await suppression.muteApp("Discord");
|
|
||||||
|
|
||||||
const service = new MeetingDetectService({
|
|
||||||
detector,
|
|
||||||
notifier,
|
|
||||||
suppression,
|
|
||||||
matchBrowser: async () => null,
|
|
||||||
correlate: async () => null,
|
|
||||||
toast: null,
|
|
||||||
});
|
|
||||||
await service.start();
|
|
||||||
|
|
||||||
probe.next = [{ executable: "Discord", pid: 300 }];
|
|
||||||
await detector.tick();
|
|
||||||
await service.settle();
|
|
||||||
|
|
||||||
expect(notifier.sent).toHaveLength(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,221 +0,0 @@
|
||||||
import type { INotificationService } from "@x/core/dist/application/notification/service.js";
|
|
||||||
import { MeetingDetector, type MeetingActiveEvent } from "./detector.js";
|
|
||||||
import { matchBrowserMeeting, type BrowserMeetingMatch } from "./browser-match.js";
|
|
||||||
import { correlateNow, type CorrelatedEvent } from "./calendar-correlate.js";
|
|
||||||
import { Suppression } from "./suppression.js";
|
|
||||||
import type { MeetingAppKind } from "./meeting-apps.js";
|
|
||||||
import { buildAdHocTitle, shortPlatformLabel } from "./ad-hoc-title.js";
|
|
||||||
import { MeetingToastWindow, type ToastPayload } from "./toast-window.js";
|
|
||||||
|
|
||||||
// Glue layer: turns detector events into popup notifications, gated by browser
|
|
||||||
// tab matching, calendar correlation, and the suppression store.
|
|
||||||
//
|
|
||||||
// Tests inject their own detector + notification service + suppression so this
|
|
||||||
// runs without touching the OS.
|
|
||||||
|
|
||||||
type Matcher = (executable?: string) => Promise<BrowserMeetingMatch | null>;
|
|
||||||
type Correlator = (now: Date) => Promise<CorrelatedEvent | null>;
|
|
||||||
|
|
||||||
export interface MeetingDetectServiceOptions {
|
|
||||||
detector: MeetingDetector;
|
|
||||||
notifier: INotificationService;
|
|
||||||
suppression: Suppression;
|
|
||||||
// Defaults run the real OS-touching versions; tests override.
|
|
||||||
matchBrowser?: Matcher;
|
|
||||||
correlate?: Correlator;
|
|
||||||
// Custom popup renderer. When provided (default in production), the toast
|
|
||||||
// is used instead of the native OS notification. Tests pass null to fall
|
|
||||||
// back to the notifier and assert on its calls.
|
|
||||||
toast?: { show(payload: ToastPayload): void } | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class MeetingDetectService {
|
|
||||||
private readonly detector: MeetingDetector;
|
|
||||||
private readonly notifier: INotificationService;
|
|
||||||
private readonly suppression: Suppression;
|
|
||||||
private readonly matchBrowser: Matcher;
|
|
||||||
private readonly correlate: Correlator;
|
|
||||||
private readonly toast: { show(payload: ToastPayload): void } | null;
|
|
||||||
// Track async work spawned from detector events so tests (and shutdown)
|
|
||||||
// can wait for it to settle.
|
|
||||||
private pending = new Set<Promise<void>>();
|
|
||||||
|
|
||||||
constructor(opts: MeetingDetectServiceOptions) {
|
|
||||||
this.detector = opts.detector;
|
|
||||||
this.notifier = opts.notifier;
|
|
||||||
this.suppression = opts.suppression;
|
|
||||||
this.matchBrowser = opts.matchBrowser ?? matchBrowserMeeting;
|
|
||||||
this.correlate = opts.correlate ?? ((now) => correlateNow(now));
|
|
||||||
// `toast` is explicitly nullable so tests can opt out. Undefined →
|
|
||||||
// build the real one. Null → use the native notifier instead.
|
|
||||||
this.toast = opts.toast === undefined ? new MeetingToastWindow() : opts.toast;
|
|
||||||
}
|
|
||||||
|
|
||||||
async start(): Promise<void> {
|
|
||||||
await this.suppression.init();
|
|
||||||
if (!this.notifier.isSupported()) {
|
|
||||||
console.warn("[MeetingDetect] notification service unsupported; detector will run but no popups will fire");
|
|
||||||
}
|
|
||||||
this.detector.on("meeting-active", (event) => {
|
|
||||||
const work = this.handleActive(event).catch((err) => {
|
|
||||||
console.error("[MeetingDetect] handleActive failed:", err);
|
|
||||||
});
|
|
||||||
this.pending.add(work);
|
|
||||||
void work.finally(() => this.pending.delete(work));
|
|
||||||
});
|
|
||||||
this.detector.on("meeting-cleared", (event) => {
|
|
||||||
// Mic released → drop the session's suppression so the next call
|
|
||||||
// (same Chrome process, new Meet) can fire again.
|
|
||||||
this.suppression.clearSession(event.sessionKey).catch((err) => {
|
|
||||||
console.error("[MeetingDetect] clearSession failed:", err);
|
|
||||||
});
|
|
||||||
console.log(`[MeetingDetect] session cleared: ${event.sessionKey}`);
|
|
||||||
});
|
|
||||||
this.detector.start();
|
|
||||||
console.log("[MeetingDetect] service started — polling for meeting apps holding the mic");
|
|
||||||
}
|
|
||||||
|
|
||||||
stop(): void {
|
|
||||||
this.detector.stop();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Test hook — resolves once all in-flight handleActive() calls complete. */
|
|
||||||
async settle(): Promise<void> {
|
|
||||||
while (this.pending.size > 0) {
|
|
||||||
await Promise.all([...this.pending]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleActive(event: MeetingActiveEvent): Promise<void> {
|
|
||||||
console.log(`[MeetingDetect] active: ${event.executable} (kind=${event.kind})`);
|
|
||||||
if (!this.suppression.shouldNotify(event.sessionKey, event.executable)) {
|
|
||||||
console.log(`[MeetingDetect] suppressed (already notified or muted): ${event.sessionKey}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// For browsers we MUST confirm the foreground tab is a meeting page —
|
|
||||||
// otherwise we'd popup for YouTube, Spotify web, etc.
|
|
||||||
let browserMatch: BrowserMeetingMatch | null = null;
|
|
||||||
if (event.kind === "browser") {
|
|
||||||
browserMatch = await this.matchBrowser(event.executable);
|
|
||||||
if (!browserMatch) return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const correlated = await this.correlate(new Date()).catch(() => null);
|
|
||||||
|
|
||||||
// Ad-hoc only: compute "Meeting Notes - <Platform> [#N]" so the note
|
|
||||||
// file lands with a useful title. Skip when we have a real calendar
|
|
||||||
// event — that already provides the right summary.
|
|
||||||
let adHocTitle: string | undefined;
|
|
||||||
if (!correlated) {
|
|
||||||
const short = shortPlatformLabel({
|
|
||||||
browserPlatform: browserMatch?.platform,
|
|
||||||
kind: event.kind,
|
|
||||||
});
|
|
||||||
if (short) {
|
|
||||||
adHocTitle = await buildAdHocTitle({ platformLabel: short }).catch((err) => {
|
|
||||||
console.error("[MeetingDetect] buildAdHocTitle failed:", err);
|
|
||||||
return `Meeting Notes - ${short}`;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = buildPopup(event.kind, browserMatch, correlated, adHocTitle);
|
|
||||||
if (!payload) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (this.toast) {
|
|
||||||
this.toast.show({
|
|
||||||
title: payload.toast.title,
|
|
||||||
subtitle: payload.toast.subtitle,
|
|
||||||
actionLabel: payload.notify.actionLabel,
|
|
||||||
actionLink: payload.notify.link,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
this.notifier.notify(payload.notify);
|
|
||||||
}
|
|
||||||
await this.suppression.markNotified(event.sessionKey);
|
|
||||||
console.log(`[MeetingDetect] popup fired for ${event.executable} (kind=${event.kind}, eventId=${correlated?.eventId ?? "ad-hoc"})`);
|
|
||||||
} catch (err) {
|
|
||||||
console.error("[MeetingDetect] popup failed:", err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface BuiltPopup {
|
|
||||||
notify: {
|
|
||||||
title: string;
|
|
||||||
message: string;
|
|
||||||
link: string;
|
|
||||||
actionLabel: string;
|
|
||||||
};
|
|
||||||
// Toast-specific fields (subtitle is the secondary line; the native
|
|
||||||
// notification API only has one body string, so we collapse title+subtitle
|
|
||||||
// into `message` when falling back to the OS notifier).
|
|
||||||
toast: {
|
|
||||||
title: string;
|
|
||||||
subtitle: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildPopup(
|
|
||||||
kind: MeetingAppKind,
|
|
||||||
browserMatch: BrowserMeetingMatch | null,
|
|
||||||
correlated: CorrelatedEvent | null,
|
|
||||||
adHocTitle?: string,
|
|
||||||
): BuiltPopup | null {
|
|
||||||
const platformLabel = describePlatform(kind, browserMatch);
|
|
||||||
if (!platformLabel) return null;
|
|
||||||
|
|
||||||
if (correlated) {
|
|
||||||
const toastTitle = correlated.summary;
|
|
||||||
const toastSubtitle = `On ${platformLabel}`;
|
|
||||||
return {
|
|
||||||
notify: {
|
|
||||||
title: "Take notes for this meeting?",
|
|
||||||
message: `${correlated.summary} — on ${platformLabel}. Click to capture notes with Rowboat.`,
|
|
||||||
link: `rowboat://action?type=take-meeting-notes&eventId=${encodeURIComponent(correlated.eventId)}`,
|
|
||||||
actionLabel: "Start taking notes",
|
|
||||||
},
|
|
||||||
toast: { title: toastTitle, subtitle: toastSubtitle },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ad-hoc — no calendar event matched. Use the precomputed counter-aware
|
|
||||||
// title ("Meeting Notes - Zoom" / "... #2") if available; fall back to a
|
|
||||||
// simple platform-suffixed title.
|
|
||||||
const title = adHocTitle ?? `Meeting Notes - ${platformLabel}`;
|
|
||||||
return {
|
|
||||||
notify: {
|
|
||||||
title: "You are in a meeting",
|
|
||||||
message: `Detected on ${platformLabel}. Click to take notes with Rowboat.`,
|
|
||||||
link: `rowboat://action?type=take-meeting-notes&title=${encodeURIComponent(title)}`,
|
|
||||||
actionLabel: "Start taking notes",
|
|
||||||
},
|
|
||||||
toast: {
|
|
||||||
title: "You are in a meeting",
|
|
||||||
subtitle: `Detected on ${platformLabel}`,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function describePlatform(kind: MeetingAppKind, browserMatch: BrowserMeetingMatch | null): string | null {
|
|
||||||
if (browserMatch) {
|
|
||||||
switch (browserMatch.platform) {
|
|
||||||
case "google-meet": return "Google Meet";
|
|
||||||
case "zoom-web": return "Zoom";
|
|
||||||
case "teams-web": return "Microsoft Teams";
|
|
||||||
case "slack-huddle": return "Slack huddle";
|
|
||||||
case "webex-web": return "Webex";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
switch (kind) {
|
|
||||||
case "zoom": return "Zoom";
|
|
||||||
case "teams": return "Microsoft Teams";
|
|
||||||
case "slack": return "Slack";
|
|
||||||
case "discord": return "Discord";
|
|
||||||
case "webex": return "Webex";
|
|
||||||
case "browser": return null; // shouldn't happen — caller bails before us when no browserMatch
|
|
||||||
case "unknown": return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,78 +0,0 @@
|
||||||
import { describe, it, expect, beforeEach } from "vitest";
|
|
||||||
import { Suppression, InMemorySuppressionStore } from "./suppression.js";
|
|
||||||
|
|
||||||
describe("Suppression", () => {
|
|
||||||
let store: InMemorySuppressionStore;
|
|
||||||
let suppression: Suppression;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
store = new InMemorySuppressionStore();
|
|
||||||
suppression = new Suppression(store);
|
|
||||||
await suppression.init();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("allows the first popup for a fresh session", () => {
|
|
||||||
expect(suppression.shouldNotify("zoom.us#100", "zoom.us")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("blocks re-popup for the same session once marked notified", async () => {
|
|
||||||
await suppression.markNotified("zoom.us#100");
|
|
||||||
expect(suppression.shouldNotify("zoom.us#100", "zoom.us")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("allows a different session for the same exe", async () => {
|
|
||||||
await suppression.markNotified("zoom.us#100");
|
|
||||||
expect(suppression.shouldNotify("zoom.us#101", "zoom.us")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("respects the dismiss cooldown window", async () => {
|
|
||||||
// Anchor at "now" — gc() filters by wall-clock age, so a hard-coded
|
|
||||||
// past date would be dropped on persist and the cooldown wouldn't apply.
|
|
||||||
const t0 = new Date();
|
|
||||||
await suppression.markDismissed("/Applications/zoom.us.app/Contents/MacOS/zoom.us", t0);
|
|
||||||
|
|
||||||
const within = new Date(t0.getTime() + 10 * 60 * 1000); // 10 min later
|
|
||||||
expect(suppression.shouldNotify("zoom.us#200", "zoom.us", within)).toBe(false);
|
|
||||||
|
|
||||||
const after = new Date(t0.getTime() + 31 * 60 * 1000); // 31 min later — past 30-min cooldown
|
|
||||||
// Cooldown GC drops entries past the window — re-load to apply GC.
|
|
||||||
const reloaded = new Suppression(store);
|
|
||||||
await reloaded.init();
|
|
||||||
expect(reloaded.shouldNotify("zoom.us#200", "zoom.us", after)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("permanently mutes an app", async () => {
|
|
||||||
await suppression.muteApp("/Applications/Discord.app/Contents/MacOS/Discord");
|
|
||||||
expect(suppression.shouldNotify("Discord#9", "Discord")).toBe(false);
|
|
||||||
// And after reload, still muted.
|
|
||||||
const reloaded = new Suppression(store);
|
|
||||||
await reloaded.init();
|
|
||||||
expect(reloaded.shouldNotify("Discord#10", "Discord")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("persists state through save/load", async () => {
|
|
||||||
await suppression.markNotified("zoom.us#100");
|
|
||||||
await suppression.muteApp("Discord");
|
|
||||||
|
|
||||||
const snap = store.snapshot();
|
|
||||||
expect(snap.notifiedSessions["zoom.us#100"]).toBeDefined();
|
|
||||||
expect(snap.mutedApps).toContain("discord");
|
|
||||||
|
|
||||||
const reloaded = new Suppression(store);
|
|
||||||
await reloaded.init();
|
|
||||||
expect(reloaded.shouldNotify("zoom.us#100", "zoom.us")).toBe(false);
|
|
||||||
expect(reloaded.isMuted("Discord")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("dismiss key normalizes path differences (Win path vs basename)", async () => {
|
|
||||||
const winPath = "C:\\Program Files\\Zoom\\bin\\Zoom.exe";
|
|
||||||
const macPath = "/Applications/Zoom.app/Contents/MacOS/zoom.us";
|
|
||||||
|
|
||||||
// Mute via mac-style path, expect it to apply when the detector reports the Windows-style path
|
|
||||||
// only if the basename matches. zoom.exe vs zoom.us differ, so they should NOT cross-match
|
|
||||||
// — verifying the dismiss key is the bare exe name and we don't over-match.
|
|
||||||
await suppression.muteApp(winPath);
|
|
||||||
expect(suppression.isMuted(winPath)).toBe(true);
|
|
||||||
expect(suppression.isMuted(macPath)).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,163 +0,0 @@
|
||||||
import path from "node:path";
|
|
||||||
import fs from "node:fs/promises";
|
|
||||||
import { WorkDir } from "@x/core/dist/config/config.js";
|
|
||||||
|
|
||||||
const STATE_FILE = path.join(WorkDir, "meeting_detect_state.json");
|
|
||||||
// Don't re-popup for the same exe within this window if the user dismissed.
|
|
||||||
const DISMISS_COOLDOWN_MS = 30 * 60 * 1000;
|
|
||||||
// Drop session-key entries older than 24h.
|
|
||||||
const SESSION_TTL_MS = 24 * 60 * 60 * 1000;
|
|
||||||
|
|
||||||
interface SuppressionState {
|
|
||||||
// Mic sessions we've already shown a popup for — keyed by detector sessionKey.
|
|
||||||
notifiedSessions: Record<string, { notifiedAt: string }>;
|
|
||||||
// User explicitly dismissed for this exe at this time.
|
|
||||||
recentlyDismissed: Record<string, { dismissedAt: string }>;
|
|
||||||
// Permanent "never offer for this app" list — exe substring matches.
|
|
||||||
mutedApps: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
function empty(): SuppressionState {
|
|
||||||
return { notifiedSessions: {}, recentlyDismissed: {}, mutedApps: [] };
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SuppressionStore {
|
|
||||||
load(): Promise<SuppressionState>;
|
|
||||||
save(state: SuppressionState): Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
class FileSuppressionStore implements SuppressionStore {
|
|
||||||
private readonly file: string;
|
|
||||||
constructor(file: string) { this.file = file; }
|
|
||||||
|
|
||||||
async load(): Promise<SuppressionState> {
|
|
||||||
try {
|
|
||||||
const raw = await fs.readFile(this.file, "utf-8");
|
|
||||||
const parsed = JSON.parse(raw);
|
|
||||||
return normalize(parsed);
|
|
||||||
} catch {
|
|
||||||
return empty();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async save(state: SuppressionState): Promise<void> {
|
|
||||||
const tmp = `${this.file}.tmp`;
|
|
||||||
await fs.writeFile(tmp, JSON.stringify(state, null, 2), "utf-8");
|
|
||||||
await fs.rename(tmp, this.file);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalize(raw: unknown): SuppressionState {
|
|
||||||
if (!raw || typeof raw !== "object") return empty();
|
|
||||||
const obj = raw as Partial<SuppressionState>;
|
|
||||||
return {
|
|
||||||
notifiedSessions: obj.notifiedSessions && typeof obj.notifiedSessions === "object" ? obj.notifiedSessions : {},
|
|
||||||
recentlyDismissed: obj.recentlyDismissed && typeof obj.recentlyDismissed === "object" ? obj.recentlyDismissed : {},
|
|
||||||
mutedApps: Array.isArray(obj.mutedApps) ? obj.mutedApps.filter((x) => typeof x === "string") : [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Suppression {
|
|
||||||
private readonly store: SuppressionStore;
|
|
||||||
private state: SuppressionState = empty();
|
|
||||||
private loaded = false;
|
|
||||||
|
|
||||||
constructor(store?: SuppressionStore) {
|
|
||||||
this.store = store ?? new FileSuppressionStore(STATE_FILE);
|
|
||||||
}
|
|
||||||
|
|
||||||
async init(): Promise<void> {
|
|
||||||
this.state = gc(await this.store.load());
|
|
||||||
this.loaded = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Should we fire a popup for this (sessionKey, executable)? */
|
|
||||||
shouldNotify(sessionKey: string, executable: string, now: Date = new Date()): boolean {
|
|
||||||
if (!this.loaded) return true; // fail open — better to occasionally re-popup than to silently miss.
|
|
||||||
if (this.isMuted(executable)) return false;
|
|
||||||
if (this.state.notifiedSessions[sessionKey]) return false;
|
|
||||||
|
|
||||||
const dismissKey = dismissKeyFor(executable);
|
|
||||||
const recent = this.state.recentlyDismissed[dismissKey];
|
|
||||||
if (recent) {
|
|
||||||
const dismissedAt = Date.parse(recent.dismissedAt);
|
|
||||||
if (Number.isFinite(dismissedAt) && now.getTime() - dismissedAt < DISMISS_COOLDOWN_MS) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
async markNotified(sessionKey: string, now: Date = new Date()): Promise<void> {
|
|
||||||
this.state.notifiedSessions[sessionKey] = { notifiedAt: now.toISOString() };
|
|
||||||
await this.persist();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear the notified mark for a session. Called when the detector observes
|
|
||||||
* the mic being released — without this, on Windows (no pid in sessionKey)
|
|
||||||
* the same browser would never re-fire because every new Meet call reuses
|
|
||||||
* the same exe-keyed session.
|
|
||||||
*/
|
|
||||||
async clearSession(sessionKey: string): Promise<void> {
|
|
||||||
if (!this.state.notifiedSessions[sessionKey]) return;
|
|
||||||
delete this.state.notifiedSessions[sessionKey];
|
|
||||||
await this.persist();
|
|
||||||
}
|
|
||||||
|
|
||||||
async markDismissed(executable: string, now: Date = new Date()): Promise<void> {
|
|
||||||
this.state.recentlyDismissed[dismissKeyFor(executable)] = { dismissedAt: now.toISOString() };
|
|
||||||
await this.persist();
|
|
||||||
}
|
|
||||||
|
|
||||||
async muteApp(executable: string): Promise<void> {
|
|
||||||
const key = dismissKeyFor(executable);
|
|
||||||
if (!this.state.mutedApps.includes(key)) {
|
|
||||||
this.state.mutedApps.push(key);
|
|
||||||
await this.persist();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
isMuted(executable: string): boolean {
|
|
||||||
const needle = dismissKeyFor(executable);
|
|
||||||
return this.state.mutedApps.some((m) => needle.includes(m) || m.includes(needle));
|
|
||||||
}
|
|
||||||
|
|
||||||
private async persist(): Promise<void> {
|
|
||||||
this.state = gc(this.state);
|
|
||||||
try {
|
|
||||||
await this.store.save(this.state);
|
|
||||||
} catch (err) {
|
|
||||||
console.error("[MeetingDetect] failed to persist suppression state:", err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function dismissKeyFor(executable: string): string {
|
|
||||||
// Reduce a path/exe to a stable key — strip directory, lowercase.
|
|
||||||
const base = executable.replace(/^.*[/\\]/, "").toLowerCase();
|
|
||||||
return base || executable.toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
function gc(state: SuppressionState): SuppressionState {
|
|
||||||
const now = Date.now();
|
|
||||||
const sessions: SuppressionState["notifiedSessions"] = {};
|
|
||||||
for (const [k, v] of Object.entries(state.notifiedSessions)) {
|
|
||||||
const ts = Date.parse(v.notifiedAt);
|
|
||||||
if (Number.isFinite(ts) && now - ts < SESSION_TTL_MS) sessions[k] = v;
|
|
||||||
}
|
|
||||||
const dismissed: SuppressionState["recentlyDismissed"] = {};
|
|
||||||
for (const [k, v] of Object.entries(state.recentlyDismissed)) {
|
|
||||||
const ts = Date.parse(v.dismissedAt);
|
|
||||||
if (Number.isFinite(ts) && now - ts < DISMISS_COOLDOWN_MS) dismissed[k] = v;
|
|
||||||
}
|
|
||||||
return { notifiedSessions: sessions, recentlyDismissed: dismissed, mutedApps: state.mutedApps };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** In-memory store for tests. */
|
|
||||||
export class InMemorySuppressionStore implements SuppressionStore {
|
|
||||||
private state: SuppressionState = empty();
|
|
||||||
async load(): Promise<SuppressionState> { return JSON.parse(JSON.stringify(this.state)); }
|
|
||||||
async save(s: SuppressionState): Promise<void> { this.state = JSON.parse(JSON.stringify(s)); }
|
|
||||||
snapshot(): SuppressionState { return JSON.parse(JSON.stringify(this.state)); }
|
|
||||||
}
|
|
||||||
|
|
@ -1,56 +0,0 @@
|
||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import { buildToastHtml } from "./toast-window.js";
|
|
||||||
|
|
||||||
describe("buildToastHtml", () => {
|
|
||||||
it("renders title, subtitle, CTA and a link to the rowboat deeplink", () => {
|
|
||||||
const html = buildToastHtml({
|
|
||||||
title: "You are in a meeting",
|
|
||||||
subtitle: "Detected on Google Meet",
|
|
||||||
actionLabel: "Start taking notes",
|
|
||||||
actionLink: "rowboat://action?type=take-meeting-notes&title=Meeting%20Notes%20-%20Meet",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(html).toContain("You are in a meeting");
|
|
||||||
expect(html).toContain("Detected on Google Meet");
|
|
||||||
expect(html).toContain("Start taking notes");
|
|
||||||
expect(html).toContain("rowboat://action?type=take-meeting-notes");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("includes the rowboat wordmark and accessibility attributes", () => {
|
|
||||||
const html = buildToastHtml({
|
|
||||||
title: "x", subtitle: "y", actionLabel: "Go", actionLink: "rowboat://action",
|
|
||||||
});
|
|
||||||
expect(html).toContain(">rowboat<");
|
|
||||||
expect(html).toContain('role="alert"');
|
|
||||||
expect(html).toContain('aria-live="polite"');
|
|
||||||
expect(html).toContain('aria-label="Dismiss meeting notification"');
|
|
||||||
});
|
|
||||||
|
|
||||||
it("includes a dismiss link the window will intercept", () => {
|
|
||||||
const html = buildToastHtml({
|
|
||||||
title: "x", subtitle: "y", actionLabel: "Go", actionLink: "rowboat://action",
|
|
||||||
});
|
|
||||||
expect(html).toContain("rowboat-toast://dismiss");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("escapes HTML in title/subtitle so a Meet titled `<script>` can't break the toast", () => {
|
|
||||||
const html = buildToastHtml({
|
|
||||||
title: "<script>alert(1)</script>",
|
|
||||||
subtitle: "& < > \" '",
|
|
||||||
actionLabel: "ok",
|
|
||||||
actionLink: "rowboat://action",
|
|
||||||
});
|
|
||||||
expect(html).not.toContain("<script>alert(1)</script>");
|
|
||||||
expect(html).toContain("<script>alert(1)</script>");
|
|
||||||
expect(html).toContain("& < > " '");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("escapes the action link so a malicious title in the URL can't break out of the href quotes", () => {
|
|
||||||
const html = buildToastHtml({
|
|
||||||
title: "x", subtitle: "y", actionLabel: "ok",
|
|
||||||
actionLink: `rowboat://action?title=evil"onerror=alert(1)`,
|
|
||||||
});
|
|
||||||
expect(html).not.toContain(`"onerror=alert(1)`);
|
|
||||||
expect(html).toContain(""onerror=alert(1)");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,214 +0,0 @@
|
||||||
import { BrowserWindow, screen } from "electron";
|
|
||||||
import { dispatchUrl } from "../deeplink.js";
|
|
||||||
|
|
||||||
// Notion-style meeting toast: top-center frameless window with our own HTML.
|
|
||||||
// Persistent — closes only when the user clicks the CTA or the X.
|
|
||||||
//
|
|
||||||
// Spec: white card, top: 24px, max-width 640, slide-down entry animation.
|
|
||||||
|
|
||||||
const TOAST_WIDTH = 560;
|
|
||||||
const TOAST_HEIGHT = 92;
|
|
||||||
const TOAST_TOP_MARGIN = 24;
|
|
||||||
|
|
||||||
export interface ToastPayload {
|
|
||||||
title: string;
|
|
||||||
subtitle: string;
|
|
||||||
actionLabel: string;
|
|
||||||
actionLink: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Build the self-contained HTML the toast window renders. Pure — tested. */
|
|
||||||
export function buildToastHtml(payload: ToastPayload): string {
|
|
||||||
return `<!doctype html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<style>
|
|
||||||
html, body { margin: 0; padding: 0; height: 100%; background: transparent; overflow: hidden; }
|
|
||||||
body { font-family: -apple-system, "Segoe UI", Roboto, "Helvetica Neue", sans-serif; color: #0A0A0A; }
|
|
||||||
.card {
|
|
||||||
position: relative;
|
|
||||||
box-sizing: border-box;
|
|
||||||
max-width: 560px;
|
|
||||||
width: 100%;
|
|
||||||
background: #FFFFFF;
|
|
||||||
border-radius: 14px;
|
|
||||||
padding: 16px 44px 16px 20px; /* extra right padding to clear the X */
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 16px;
|
|
||||||
box-shadow: 0 4px 24px rgba(0,0,0,0.08), 0 1px 3px rgba(0,0,0,0.04);
|
|
||||||
-webkit-app-region: drag;
|
|
||||||
animation: slidein 300ms cubic-bezier(0.16, 1, 0.3, 1);
|
|
||||||
}
|
|
||||||
@keyframes slidein {
|
|
||||||
from { transform: translateY(-20px); opacity: 0; }
|
|
||||||
to { transform: translateY(0); opacity: 1; }
|
|
||||||
}
|
|
||||||
.wordmark {
|
|
||||||
font-weight: 700;
|
|
||||||
font-size: 16px;
|
|
||||||
color: #0A2540;
|
|
||||||
letter-spacing: -0.01em;
|
|
||||||
-webkit-app-region: drag;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
.divider {
|
|
||||||
width: 1px;
|
|
||||||
height: 28px;
|
|
||||||
background: #E5E7EB;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.text {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
-webkit-app-region: no-drag;
|
|
||||||
}
|
|
||||||
.title {
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 15px;
|
|
||||||
color: #0A0A0A;
|
|
||||||
line-height: 1.25;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.subtitle {
|
|
||||||
font-weight: 400;
|
|
||||||
font-size: 13px;
|
|
||||||
color: #6B7280;
|
|
||||||
line-height: 1.3;
|
|
||||||
margin-top: 3px;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
a.cta {
|
|
||||||
-webkit-app-region: no-drag;
|
|
||||||
display: inline-block;
|
|
||||||
flex-shrink: 0;
|
|
||||||
background: #0A2540;
|
|
||||||
color: #FFFFFF;
|
|
||||||
font-weight: 500;
|
|
||||||
font-size: 13px;
|
|
||||||
padding: 9px 18px;
|
|
||||||
border-radius: 8px;
|
|
||||||
text-decoration: none;
|
|
||||||
cursor: pointer;
|
|
||||||
user-select: none;
|
|
||||||
transition: background 120ms ease;
|
|
||||||
}
|
|
||||||
a.cta:hover { background: #081C33; }
|
|
||||||
a.cta:focus-visible { outline: 2px solid #0A2540; outline-offset: 2px; }
|
|
||||||
a.close {
|
|
||||||
-webkit-app-region: no-drag;
|
|
||||||
position: absolute;
|
|
||||||
top: 8px;
|
|
||||||
right: 8px;
|
|
||||||
width: 28px;
|
|
||||||
height: 28px;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
border-radius: 8px;
|
|
||||||
color: #4B5563;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 120ms ease;
|
|
||||||
}
|
|
||||||
a.close:hover { background: #F3F4F6; }
|
|
||||||
a.close:focus-visible { outline: 2px solid #0A2540; outline-offset: 2px; }
|
|
||||||
a.close svg { display: block; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="card" role="alert" aria-live="polite">
|
|
||||||
<div class="wordmark">rowboat</div>
|
|
||||||
<div class="divider" aria-hidden="true"></div>
|
|
||||||
<div class="text">
|
|
||||||
<div class="title">${escapeHtml(payload.title)}</div>
|
|
||||||
<div class="subtitle">${escapeHtml(payload.subtitle)}</div>
|
|
||||||
</div>
|
|
||||||
<a class="cta" href="${escapeAttr(payload.actionLink)}">${escapeHtml(payload.actionLabel)}</a>
|
|
||||||
<a class="close" href="rowboat-toast://dismiss" aria-label="Dismiss meeting notification">
|
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
||||||
<path d="M18 6 6 18"/>
|
|
||||||
<path d="m6 6 12 12"/>
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeHtml(s: string): string {
|
|
||||||
return s.replace(/[&<>"']/g, (c) => ({
|
|
||||||
"&": "&", "<": "<", ">": ">", '"': """, "'": "'",
|
|
||||||
}[c]!));
|
|
||||||
}
|
|
||||||
function escapeAttr(s: string): string { return escapeHtml(s); }
|
|
||||||
|
|
||||||
export class MeetingToastWindow {
|
|
||||||
private win: BrowserWindow | null = null;
|
|
||||||
|
|
||||||
show(payload: ToastPayload): void {
|
|
||||||
// If a previous toast is still up, replace it.
|
|
||||||
this.closeImmediate();
|
|
||||||
|
|
||||||
const display = screen.getPrimaryDisplay();
|
|
||||||
const wa = display.workArea;
|
|
||||||
const x = Math.round(wa.x + (wa.width - TOAST_WIDTH) / 2);
|
|
||||||
const y = wa.y + TOAST_TOP_MARGIN;
|
|
||||||
|
|
||||||
const win = new BrowserWindow({
|
|
||||||
width: TOAST_WIDTH,
|
|
||||||
height: TOAST_HEIGHT,
|
|
||||||
x, y,
|
|
||||||
frame: false,
|
|
||||||
transparent: true,
|
|
||||||
resizable: false,
|
|
||||||
movable: false,
|
|
||||||
minimizable: false,
|
|
||||||
maximizable: false,
|
|
||||||
fullscreenable: false,
|
|
||||||
skipTaskbar: true,
|
|
||||||
alwaysOnTop: true,
|
|
||||||
focusable: false,
|
|
||||||
show: false,
|
|
||||||
hasShadow: false,
|
|
||||||
webPreferences: {
|
|
||||||
nodeIntegration: false,
|
|
||||||
contextIsolation: true,
|
|
||||||
sandbox: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
win.setAlwaysOnTop(true, "screen-saver");
|
|
||||||
win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
|
|
||||||
|
|
||||||
win.webContents.on("will-navigate", (event, url) => {
|
|
||||||
event.preventDefault();
|
|
||||||
if (url.startsWith("rowboat-toast://")) {
|
|
||||||
this.closeImmediate();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (url.startsWith("rowboat://")) {
|
|
||||||
dispatchUrl(url);
|
|
||||||
this.closeImmediate();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
win.once("ready-to-show", () => win.show());
|
|
||||||
win.on("closed", () => { if (this.win === win) this.win = null; });
|
|
||||||
|
|
||||||
const html = buildToastHtml(payload);
|
|
||||||
win.loadURL("data:text/html;charset=utf-8," + encodeURIComponent(html));
|
|
||||||
|
|
||||||
this.win = win;
|
|
||||||
// No auto-dismiss — persistent until X or CTA click (per spec).
|
|
||||||
}
|
|
||||||
|
|
||||||
closeImmediate(): void {
|
|
||||||
if (this.win && !this.win.isDestroyed()) this.win.close();
|
|
||||||
this.win = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
export interface MicUser {
|
|
||||||
// Best-effort executable identifier — full path on Windows, command name on macOS.
|
|
||||||
executable: string;
|
|
||||||
// Process id when the platform exposes it (macOS via lsof). Undefined on Windows
|
|
||||||
// because the registry only records the exe path, not which pid is currently
|
|
||||||
// holding the mic.
|
|
||||||
pid?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MicProbe {
|
|
||||||
probe(): Promise<MicUser[]>;
|
|
||||||
}
|
|
||||||
|
|
@ -508,7 +508,7 @@ export async function disconnectProvider(provider: string): Promise<{ success: b
|
||||||
if (connection.mode === 'rowboat' && connection.tokens?.access_token) {
|
if (connection.mode === 'rowboat' && connection.tokens?.access_token) {
|
||||||
try {
|
try {
|
||||||
const revokeUrl = `https://oauth2.googleapis.com/revoke?token=${encodeURIComponent(connection.tokens.access_token)}`;
|
const revokeUrl = `https://oauth2.googleapis.com/revoke?token=${encodeURIComponent(connection.tokens.access_token)}`;
|
||||||
const res = await fetch(revokeUrl, { method: 'POST' });
|
const res = await fetch(revokeUrl, { method: 'POST', signal: AbortSignal.timeout(5000) });
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
console.warn(`[OAuth] Google revoke returned ${res.status}; continuing with local disconnect`);
|
console.warn(`[OAuth] Google revoke returned ${res.status}; continuing with local disconnect`);
|
||||||
}
|
}
|
||||||
|
|
@ -532,6 +532,81 @@ export async function disconnectProvider(provider: string): Promise<{ success: b
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Startup migration for Google scope changes. When a connected Google grant was
|
||||||
|
* issued before a scope was added (e.g. old installs on gmail.readonly that
|
||||||
|
* never received gmail.modify), invalidate it so the user is prompted to
|
||||||
|
* reconnect and re-grant with the current scopes. The currently-requested
|
||||||
|
* scopes in the provider config are the source of truth: a grant missing any
|
||||||
|
* of them is treated as stale.
|
||||||
|
*
|
||||||
|
* We revoke + clear the stale token but DELIBERATELY keep the provider entry
|
||||||
|
* with an `error` set rather than calling disconnectProvider (which deletes the
|
||||||
|
* whole entry). The renderer's reconnect prompts — the sidebar "Reconnect your
|
||||||
|
* accounts" alert and the connectors "Reconnect" row — key off this `error`
|
||||||
|
* field, not off the connected flag. A fully deleted entry has no error and is
|
||||||
|
* indistinguishable from "never connected", so no prompt would ever appear.
|
||||||
|
*
|
||||||
|
* Tokens with no recorded scopes (very old installs that never persisted them)
|
||||||
|
* are also treated as stale. Safe to call on every startup — it's a no-op once
|
||||||
|
* the grant covers all current scopes, and once invalidated the early return on
|
||||||
|
* the missing token keeps it from re-running until the user reconnects.
|
||||||
|
*/
|
||||||
|
export async function disconnectGoogleIfScopesStale(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const oauthRepo = getOAuthRepo();
|
||||||
|
const connection = await oauthRepo.read('google');
|
||||||
|
|
||||||
|
// Not connected (or already invalidated) — nothing to migrate.
|
||||||
|
if (!connection.tokens) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const providerConfig = await getProviderConfig('google');
|
||||||
|
const requiredScopes = providerConfig.scopes ?? [];
|
||||||
|
if (requiredScopes.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const granted = new Set(connection.tokens.scopes ?? []);
|
||||||
|
const missingScopes = requiredScopes.filter((scope) => !granted.has(scope));
|
||||||
|
if (missingScopes.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[OAuth] Google grant is missing current scopes [${missingScopes.join(', ')}]; ` +
|
||||||
|
'invalidating it so the user is prompted to reconnect with the new scopes.'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Best-effort revoke at Google for rowboat-mode grants (mirrors disconnectProvider).
|
||||||
|
if (connection.mode === 'rowboat' && connection.tokens.access_token) {
|
||||||
|
try {
|
||||||
|
const revokeUrl = `https://oauth2.googleapis.com/revoke?token=${encodeURIComponent(connection.tokens.access_token)}`;
|
||||||
|
const res = await fetch(revokeUrl, { method: 'POST', signal: AbortSignal.timeout(5000) });
|
||||||
|
if (!res.ok) {
|
||||||
|
console.warn(`[OAuth] Google revoke returned ${res.status}; continuing with local invalidation`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[OAuth] Google revoke failed; continuing with local invalidation:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drop the stale token but keep the entry with an error so the reconnect
|
||||||
|
// prompt fires (see the note above).
|
||||||
|
await oauthRepo.upsert('google', {
|
||||||
|
tokens: null,
|
||||||
|
error: 'Google permissions changed. Please reconnect to continue.',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Nudge any already-open window to re-read state. The renderer's initial
|
||||||
|
// mount also re-reads, so the prompt shows even if no window is up yet.
|
||||||
|
emitOAuthEvent({ provider: 'google', success: false });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[OAuth] Google scope migration check failed:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get access token for a provider (internal use only)
|
* Get access token for a provider (internal use only)
|
||||||
* Refreshes token if expired
|
* Refreshes token if expired
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,5 @@
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
"src"
|
"src"
|
||||||
],
|
|
||||||
"exclude": [
|
|
||||||
"src/**/*.test.ts"
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
import { defineConfig } from 'vitest/config';
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
test: {
|
|
||||||
environment: 'node',
|
|
||||||
include: ['src/**/*.test.ts'],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@eigenpal/docx-editor-react": "^1.0.3",
|
||||||
"@radix-ui/react-avatar": "^1.1.11",
|
"@radix-ui/react-avatar": "^1.1.11",
|
||||||
"@radix-ui/react-collapsible": "^1.1.12",
|
"@radix-ui/react-collapsible": "^1.1.12",
|
||||||
"@radix-ui/react-context-menu": "^2.2.16",
|
"@radix-ui/react-context-menu": "^2.2.16",
|
||||||
|
|
@ -46,6 +47,15 @@
|
||||||
"motion": "^12.23.26",
|
"motion": "^12.23.26",
|
||||||
"nanoid": "^5.1.6",
|
"nanoid": "^5.1.6",
|
||||||
"posthog-js": "^1.332.0",
|
"posthog-js": "^1.332.0",
|
||||||
|
"prosemirror-commands": "^1.7.1",
|
||||||
|
"prosemirror-dropcursor": "^1.8.2",
|
||||||
|
"prosemirror-history": "^1.5.0",
|
||||||
|
"prosemirror-keymap": "^1.2.3",
|
||||||
|
"prosemirror-model": "^1.25.7",
|
||||||
|
"prosemirror-state": "^1.4.4",
|
||||||
|
"prosemirror-tables": "^1.8.5",
|
||||||
|
"prosemirror-transform": "^1.12.0",
|
||||||
|
"prosemirror-view": "^1.41.8",
|
||||||
"radix-ui": "^1.4.3",
|
"radix-ui": "^1.4.3",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,30 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 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) {
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
a:nth-of-type(2) .logo {
|
a:nth-of-type(2) .logo {
|
||||||
animation: logo-spin infinite 20s linear;
|
animation: logo-spin infinite 20s linear;
|
||||||
|
|
@ -1176,6 +1200,10 @@
|
||||||
--scrollbar-track: oklch(0.95 0 0);
|
--scrollbar-track: oklch(0.95 0 0);
|
||||||
--scrollbar-thumb: oklch(0.75 0 0);
|
--scrollbar-thumb: oklch(0.75 0 0);
|
||||||
--scrollbar-thumb-hover: oklch(0.65 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-panel: oklch(0.97 0 0);
|
||||||
--rowboat-raised: oklch(1 0 0);
|
--rowboat-raised: oklch(1 0 0);
|
||||||
--rowboat-wash: color-mix(in oklab, var(--background) 88%, var(--primary) 12%);
|
--rowboat-wash: color-mix(in oklab, var(--background) 88%, var(--primary) 12%);
|
||||||
|
|
|
||||||
|
|
@ -5,19 +5,20 @@ import { RunEvent, ListRunsResponse } from '@x/shared/src/runs.js';
|
||||||
import type { LanguageModelUsage, ToolUIPart } from 'ai';
|
import type { LanguageModelUsage, ToolUIPart } from 'ai';
|
||||||
import './App.css'
|
import './App.css'
|
||||||
import z from 'zod';
|
import z from 'zod';
|
||||||
import { CheckIcon, LoaderIcon, PanelLeftIcon, ArrowRight, MessageSquare, ChevronLeftIcon, ChevronRightIcon, Plus, HistoryIcon, X } from 'lucide-react';
|
import { CheckIcon, LoaderIcon, PanelLeftIcon, ArrowLeft, ArrowRight, MessageSquare, ChevronLeftIcon, ChevronRightIcon, Plus, HistoryIcon } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { MarkdownEditor, type MarkdownEditorHandle } from './components/markdown-editor';
|
import { MarkdownEditor, type MarkdownEditorHandle } from './components/markdown-editor';
|
||||||
import { ChatSidebar } from './components/chat-sidebar';
|
import { ChatSidebar } from './components/chat-sidebar';
|
||||||
import { ChatHeader } from './components/chat-header';
|
import { ChatHeader } from './components/chat-header';
|
||||||
import { ChatEmptyState } from './components/chat-empty-state';
|
import { ChatEmptyState } from './components/chat-empty-state';
|
||||||
import { ChatInputWithMentions, type StagedAttachment } from './components/chat-input-with-mentions';
|
import { ChatInputWithMentions, type PermissionMode, type StagedAttachment } from './components/chat-input-with-mentions';
|
||||||
import { ChatMessageAttachments } from '@/components/chat-message-attachments'
|
import { ChatMessageAttachments } from '@/components/chat-message-attachments'
|
||||||
import { GraphView, type GraphEdge, type GraphNode } from '@/components/graph-view';
|
import { GraphView, type GraphEdge, type GraphNode } from '@/components/graph-view';
|
||||||
import { BasesView, type BaseConfig, DEFAULT_BASE_CONFIG } from '@/components/bases-view';
|
import { BasesView, type BaseConfig, DEFAULT_BASE_CONFIG } from '@/components/bases-view';
|
||||||
import { ImageFileViewer } from '@/components/image-file-viewer';
|
import { ImageFileViewer } from '@/components/image-file-viewer';
|
||||||
import { VideoFileViewer } from '@/components/video-file-viewer';
|
import { VideoFileViewer } from '@/components/video-file-viewer';
|
||||||
import { AudioFileViewer } from '@/components/audio-file-viewer';
|
import { AudioFileViewer } from '@/components/audio-file-viewer';
|
||||||
|
import { DocxFileViewer } from '@/components/docx-file-viewer';
|
||||||
import { PersistentViewerCache } from '@/components/persistent-viewer-cache';
|
import { PersistentViewerCache } from '@/components/persistent-viewer-cache';
|
||||||
import { UnsupportedFileViewer } from '@/components/unsupported-file-viewer';
|
import { UnsupportedFileViewer } from '@/components/unsupported-file-viewer';
|
||||||
import { getViewerType, isCacheableViewerPath } from '@/lib/file-types';
|
import { getViewerType, isCacheableViewerPath } from '@/lib/file-types';
|
||||||
|
|
@ -28,6 +29,7 @@ import { LiveNotesView } from '@/components/live-notes-view';
|
||||||
import { BgTasksView } from '@/components/bg-tasks-view';
|
import { BgTasksView } from '@/components/bg-tasks-view';
|
||||||
import { EmailView } from '@/components/email-view';
|
import { EmailView } from '@/components/email-view';
|
||||||
import { WorkspaceView } from '@/components/workspace-view';
|
import { WorkspaceView } from '@/components/workspace-view';
|
||||||
|
import { CodingRunBlock } from '@/components/coding-run';
|
||||||
import { KnowledgeView } from '@/components/knowledge-view';
|
import { KnowledgeView } from '@/components/knowledge-view';
|
||||||
import { ChatHistoryView } from '@/components/chat-history-view';
|
import { ChatHistoryView } from '@/components/chat-history-view';
|
||||||
import { HomeView } from '@/components/home-view';
|
import { HomeView } from '@/components/home-view';
|
||||||
|
|
@ -55,9 +57,10 @@ import { WebSearchResult } from '@/components/ai-elements/web-search-result';
|
||||||
import { AppActionCard } from '@/components/ai-elements/app-action-card';
|
import { AppActionCard } from '@/components/ai-elements/app-action-card';
|
||||||
import { ComposioConnectCard } from '@/components/ai-elements/composio-connect-card';
|
import { ComposioConnectCard } from '@/components/ai-elements/composio-connect-card';
|
||||||
import { PermissionRequest } from '@/components/ai-elements/permission-request';
|
import { PermissionRequest } from '@/components/ai-elements/permission-request';
|
||||||
|
import { AutoPermissionDecision } from '@/components/ai-elements/auto-permission-decision';
|
||||||
import { TerminalOutput } from '@/components/terminal-output';
|
import { TerminalOutput } from '@/components/terminal-output';
|
||||||
import { AskHumanRequest } from '@/components/ai-elements/ask-human-request';
|
import { AskHumanRequest } from '@/components/ai-elements/ask-human-request';
|
||||||
import { ToolPermissionRequestEvent, AskHumanRequestEvent } from '@x/shared/src/runs.js';
|
import { ToolPermissionAutoDecisionEvent, ToolPermissionRequestEvent, AskHumanRequestEvent } from '@x/shared/src/runs.js';
|
||||||
import {
|
import {
|
||||||
SidebarInset,
|
SidebarInset,
|
||||||
SidebarProvider,
|
SidebarProvider,
|
||||||
|
|
@ -74,7 +77,7 @@ import { splitFrontmatter, joinFrontmatter } from '@/lib/frontmatter'
|
||||||
import { extractConferenceLink } from '@/lib/calendar-event'
|
import { extractConferenceLink } from '@/lib/calendar-event'
|
||||||
import { OnboardingModal } from '@/components/onboarding'
|
import { OnboardingModal } from '@/components/onboarding'
|
||||||
import { ComposioGoogleMigrationModal } from '@/components/composio-google-migration-modal'
|
import { ComposioGoogleMigrationModal } from '@/components/composio-google-migration-modal'
|
||||||
import { CommandPalette, type CommandPaletteMention } from '@/components/search-dialog'
|
import { CommandPalette, type CommandPaletteMention, type SearchType } from '@/components/search-dialog'
|
||||||
import { LiveNoteSidebar } from '@/components/live-note-sidebar'
|
import { LiveNoteSidebar } from '@/components/live-note-sidebar'
|
||||||
import { BackgroundTaskDetail } from '@/components/background-task-detail'
|
import { BackgroundTaskDetail } from '@/components/background-task-detail'
|
||||||
import { BrowserPane } from '@/components/browser-pane/BrowserPane'
|
import { BrowserPane } from '@/components/browser-pane/BrowserPane'
|
||||||
|
|
@ -115,6 +118,7 @@ import { useVoiceTTS } from '@/hooks/useVoiceTTS'
|
||||||
import { useMeetingTranscription, type CalendarEventMeta } from '@/hooks/useMeetingTranscription'
|
import { useMeetingTranscription, type CalendarEventMeta } from '@/hooks/useMeetingTranscription'
|
||||||
import { useAnalyticsIdentity } from '@/hooks/useAnalyticsIdentity'
|
import { useAnalyticsIdentity } from '@/hooks/useAnalyticsIdentity'
|
||||||
import * as analytics from '@/lib/analytics'
|
import * as analytics from '@/lib/analytics'
|
||||||
|
import { useTheme } from '@/contexts/theme-context'
|
||||||
|
|
||||||
type DirEntry = z.infer<typeof workspace.DirEntry>
|
type DirEntry = z.infer<typeof workspace.DirEntry>
|
||||||
type RunEventType = z.infer<typeof RunEvent>
|
type RunEventType = z.infer<typeof RunEvent>
|
||||||
|
|
@ -163,6 +167,7 @@ function AutoScrollPre({ className, children }: { className?: string; children:
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_SIDEBAR_WIDTH = 256
|
const DEFAULT_SIDEBAR_WIDTH = 256
|
||||||
|
const DEFAULT_CHAT_PANE_WIDTH = 460
|
||||||
const wikiLinkRegex = /\[\[([^[\]]+)\]\]/g
|
const wikiLinkRegex = /\[\[([^[\]]+)\]\]/g
|
||||||
const graphPalette = [
|
const graphPalette = [
|
||||||
{ hue: 210, sat: 72, light: 52 },
|
{ hue: 210, sat: 72, light: 52 },
|
||||||
|
|
@ -581,7 +586,7 @@ type ViewState =
|
||||||
| { type: 'live-notes' }
|
| { type: 'live-notes' }
|
||||||
| { type: 'email' }
|
| { type: 'email' }
|
||||||
| { type: 'workspace'; path?: string }
|
| { type: 'workspace'; path?: string }
|
||||||
| { type: 'knowledge-view' }
|
| { type: 'knowledge-view'; folderPath?: string }
|
||||||
| { type: 'chat-history' }
|
| { type: 'chat-history' }
|
||||||
| { type: 'home' }
|
| { type: 'home' }
|
||||||
|
|
||||||
|
|
@ -591,6 +596,7 @@ function viewStatesEqual(a: ViewState, b: ViewState): boolean {
|
||||||
if (a.type === 'file' && b.type === 'file') return a.path === b.path
|
if (a.type === 'file' && b.type === 'file') return a.path === b.path
|
||||||
if (a.type === 'task' && b.type === 'task') return a.name === b.name
|
if (a.type === 'task' && b.type === 'task') return a.name === b.name
|
||||||
if (a.type === 'workspace' && b.type === 'workspace') return (a.path ?? '') === (b.path ?? '')
|
if (a.type === 'workspace' && b.type === 'workspace') return (a.path ?? '') === (b.path ?? '')
|
||||||
|
if (a.type === 'knowledge-view' && b.type === 'knowledge-view') return (a.folderPath ?? '') === (b.folderPath ?? '')
|
||||||
return true // both graph
|
return true // both graph
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -638,8 +644,10 @@ function parseDeepLink(input: string): ViewState | null {
|
||||||
const path = params.get('path')
|
const path = params.get('path')
|
||||||
return { type: 'workspace', path: path ?? undefined }
|
return { type: 'workspace', path: path ?? undefined }
|
||||||
}
|
}
|
||||||
case 'knowledge-view':
|
case 'knowledge-view': {
|
||||||
return { type: 'knowledge-view' }
|
const folderPath = params.get('folderPath')
|
||||||
|
return { type: 'knowledge-view', folderPath: folderPath ?? undefined }
|
||||||
|
}
|
||||||
case 'chat-history':
|
case 'chat-history':
|
||||||
return { type: 'chat-history' }
|
return { type: 'chat-history' }
|
||||||
case 'home':
|
case 'home':
|
||||||
|
|
@ -731,6 +739,9 @@ function ContentHeader({
|
||||||
}
|
}
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
|
const { chatPanePlacement, chatPaneSize } = useTheme()
|
||||||
|
const isChatPaneInMiddle = chatPanePlacement === 'middle'
|
||||||
|
|
||||||
type ShortcutPane = 'left' | 'right'
|
type ShortcutPane = 'left' | 'right'
|
||||||
type MarkdownHistoryHandlers = { undo: () => boolean; redo: () => boolean }
|
type MarkdownHistoryHandlers = { undo: () => boolean; redo: () => boolean }
|
||||||
|
|
||||||
|
|
@ -756,8 +767,11 @@ function App() {
|
||||||
const [isWorkspaceOpen, setIsWorkspaceOpen] = useState(false)
|
const [isWorkspaceOpen, setIsWorkspaceOpen] = useState(false)
|
||||||
const [workspaceInitialPath, setWorkspaceInitialPath] = useState<string | null>(null)
|
const [workspaceInitialPath, setWorkspaceInitialPath] = useState<string | null>(null)
|
||||||
const [isKnowledgeViewOpen, setIsKnowledgeViewOpen] = useState(false)
|
const [isKnowledgeViewOpen, setIsKnowledgeViewOpen] = useState(false)
|
||||||
|
// Folder being browsed inside the knowledge view (null = root overview).
|
||||||
|
// Lives in ViewState so folder drill-down participates in back/forward history.
|
||||||
|
const [knowledgeViewFolderPath, setKnowledgeViewFolderPath] = useState<string | null>(null)
|
||||||
const [isChatHistoryOpen, setIsChatHistoryOpen] = useState(false)
|
const [isChatHistoryOpen, setIsChatHistoryOpen] = useState(false)
|
||||||
// Default landing view: Home in the middle with the chat docked on the right.
|
// Default landing view: Home with the chat docked according to appearance settings.
|
||||||
const [isHomeOpen, setIsHomeOpen] = useState(true)
|
const [isHomeOpen, setIsHomeOpen] = useState(true)
|
||||||
const [emailInitialThreadId, setEmailInitialThreadId] = useState<string | null>(null)
|
const [emailInitialThreadId, setEmailInitialThreadId] = useState<string | null>(null)
|
||||||
const [emailThreadIdVersion, setEmailThreadIdVersion] = useState(0)
|
const [emailThreadIdVersion, setEmailThreadIdVersion] = useState(0)
|
||||||
|
|
@ -954,7 +968,7 @@ function App() {
|
||||||
voice.start()
|
voice.start()
|
||||||
}, [voice])
|
}, [voice])
|
||||||
|
|
||||||
const handlePromptSubmitRef = useRef<((message: PromptInputMessage, mentions?: FileMention[], stagedAttachments?: StagedAttachment[], searchEnabled?: boolean) => Promise<void>) | null>(null)
|
const handlePromptSubmitRef = useRef<((message: PromptInputMessage, mentions?: FileMention[], stagedAttachments?: StagedAttachment[], searchEnabled?: boolean, codeMode?: 'claude' | 'codex', permissionMode?: PermissionMode) => Promise<void>) | null>(null)
|
||||||
const pendingVoiceInputRef = useRef(false)
|
const pendingVoiceInputRef = useRef(false)
|
||||||
|
|
||||||
// Palette: per-tab editor handles for capturing cursor context on Cmd+K, and pending payload
|
// Palette: per-tab editor handles for capturing cursor context on Cmd+K, and pending payload
|
||||||
|
|
@ -1173,6 +1187,7 @@ function App() {
|
||||||
const [allPermissionRequests, setAllPermissionRequests] = useState<Map<string, z.infer<typeof ToolPermissionRequestEvent>>>(new Map())
|
const [allPermissionRequests, setAllPermissionRequests] = useState<Map<string, z.infer<typeof ToolPermissionRequestEvent>>>(new Map())
|
||||||
// Track permission responses (toolCallId -> response)
|
// Track permission responses (toolCallId -> response)
|
||||||
const [permissionResponses, setPermissionResponses] = useState<Map<string, 'approve' | 'deny'>>(new Map())
|
const [permissionResponses, setPermissionResponses] = useState<Map<string, 'approve' | 'deny'>>(new Map())
|
||||||
|
const [autoPermissionDecisions, setAutoPermissionDecisions] = useState<Map<string, z.infer<typeof ToolPermissionAutoDecisionEvent>>>(new Map())
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
chatViewStateByTabRef.current = chatViewStateByTab
|
chatViewStateByTabRef.current = chatViewStateByTab
|
||||||
|
|
@ -1186,6 +1201,7 @@ function App() {
|
||||||
pendingAskHumanRequests: new Map(pendingAskHumanRequests),
|
pendingAskHumanRequests: new Map(pendingAskHumanRequests),
|
||||||
allPermissionRequests: new Map(allPermissionRequests),
|
allPermissionRequests: new Map(allPermissionRequests),
|
||||||
permissionResponses: new Map(permissionResponses),
|
permissionResponses: new Map(permissionResponses),
|
||||||
|
autoPermissionDecisions: new Map(autoPermissionDecisions),
|
||||||
}
|
}
|
||||||
setChatViewStateByTab((prev) => ({ ...prev, [activeChatTabId]: snapshot }))
|
setChatViewStateByTab((prev) => ({ ...prev, [activeChatTabId]: snapshot }))
|
||||||
}, [
|
}, [
|
||||||
|
|
@ -1196,6 +1212,7 @@ function App() {
|
||||||
pendingAskHumanRequests,
|
pendingAskHumanRequests,
|
||||||
allPermissionRequests,
|
allPermissionRequests,
|
||||||
permissionResponses,
|
permissionResponses,
|
||||||
|
autoPermissionDecisions,
|
||||||
])
|
])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -1247,6 +1264,8 @@ function App() {
|
||||||
|
|
||||||
// Search state
|
// Search state
|
||||||
const [isSearchOpen, setIsSearchOpen] = useState(false)
|
const [isSearchOpen, setIsSearchOpen] = useState(false)
|
||||||
|
// Optional scope override for the next time search opens (cleared on close).
|
||||||
|
const [searchDefaultScope, setSearchDefaultScope] = useState<SearchType | undefined>(undefined)
|
||||||
|
|
||||||
// Background tasks state
|
// Background tasks state
|
||||||
type BackgroundTaskItem = {
|
type BackgroundTaskItem = {
|
||||||
|
|
@ -2017,6 +2036,7 @@ function App() {
|
||||||
// Track permission requests and responses from history
|
// Track permission requests and responses from history
|
||||||
const allPermissionRequests = new Map<string, z.infer<typeof ToolPermissionRequestEvent>>()
|
const allPermissionRequests = new Map<string, z.infer<typeof ToolPermissionRequestEvent>>()
|
||||||
const permResponseMap = new Map<string, 'approve' | 'deny'>()
|
const permResponseMap = new Map<string, 'approve' | 'deny'>()
|
||||||
|
const autoPermissionDecisions = new Map<string, z.infer<typeof ToolPermissionAutoDecisionEvent>>()
|
||||||
const askHumanRequests = new Map<string, z.infer<typeof AskHumanRequestEvent>>()
|
const askHumanRequests = new Map<string, z.infer<typeof AskHumanRequestEvent>>()
|
||||||
const respondedAskHumanIds = new Set<string>()
|
const respondedAskHumanIds = new Set<string>()
|
||||||
|
|
||||||
|
|
@ -2025,6 +2045,8 @@ function App() {
|
||||||
allPermissionRequests.set(event.toolCall.toolCallId, event)
|
allPermissionRequests.set(event.toolCall.toolCallId, event)
|
||||||
} else if (event.type === 'tool-permission-response') {
|
} else if (event.type === 'tool-permission-response') {
|
||||||
permResponseMap.set(event.toolCallId, event.response)
|
permResponseMap.set(event.toolCallId, event.response)
|
||||||
|
} else if (event.type === 'tool-permission-auto-decision') {
|
||||||
|
autoPermissionDecisions.set(event.toolCallId, event)
|
||||||
} else if (event.type === 'ask-human-request') {
|
} else if (event.type === 'ask-human-request') {
|
||||||
askHumanRequests.set(event.toolCallId, event)
|
askHumanRequests.set(event.toolCallId, event)
|
||||||
} else if (event.type === 'ask-human-response') {
|
} else if (event.type === 'ask-human-response') {
|
||||||
|
|
@ -2057,6 +2079,7 @@ function App() {
|
||||||
setPendingAskHumanRequests(pendingAsks)
|
setPendingAskHumanRequests(pendingAsks)
|
||||||
setAllPermissionRequests(allPermissionRequests)
|
setAllPermissionRequests(allPermissionRequests)
|
||||||
setPermissionResponses(permResponseMap)
|
setPermissionResponses(permResponseMap)
|
||||||
|
setAutoPermissionDecisions(autoPermissionDecisions)
|
||||||
|
|
||||||
// Restore the run's per-chat work directory into the tab it was loaded into.
|
// Restore the run's per-chat work directory into the tab it was loaded into.
|
||||||
const tabId = activeChatTabIdRef.current
|
const tabId = activeChatTabIdRef.current
|
||||||
|
|
@ -2273,6 +2296,8 @@ function App() {
|
||||||
...item,
|
...item,
|
||||||
result: event.result as ToolUIPart['output'],
|
result: event.result as ToolUIPart['output'],
|
||||||
status: 'completed' as const,
|
status: 'completed' as const,
|
||||||
|
// a code_agent_run finished — drop any lingering permission card
|
||||||
|
pendingCodePermission: null,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return item
|
return item
|
||||||
|
|
@ -2290,7 +2315,7 @@ function App() {
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
|
|
||||||
if (event.toolCallId && event.toolName !== 'executeCommand') {
|
if (event.toolCallId) {
|
||||||
setToolOpenForTab(activeChatTabIdRef.current, event.toolCallId, false)
|
setToolOpenForTab(activeChatTabIdRef.current, event.toolCallId, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2353,6 +2378,43 @@ function App() {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'code-run-event': {
|
||||||
|
if (!isActiveRun) return
|
||||||
|
setConversation(prev => prev.map(item => {
|
||||||
|
if (isToolCall(item) && item.id === event.toolCallId) {
|
||||||
|
const existing = item.codeRunEvents ?? []
|
||||||
|
if (existing.length === 0) {
|
||||||
|
setToolOpenForTab(activeChatTabIdRef.current, item.id, true)
|
||||||
|
}
|
||||||
|
return { ...item, codeRunEvents: [...existing, event.event] }
|
||||||
|
}
|
||||||
|
return item
|
||||||
|
}))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'code-run-permission-request': {
|
||||||
|
if (!isActiveRun) return
|
||||||
|
setConversation(prev => prev.map(item => {
|
||||||
|
if (isToolCall(item) && item.id === event.toolCallId) {
|
||||||
|
setToolOpenForTab(activeChatTabIdRef.current, item.id, true)
|
||||||
|
return { ...item, pendingCodePermission: { requestId: event.requestId, ask: event.ask } }
|
||||||
|
}
|
||||||
|
return item
|
||||||
|
}))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'tool-permission-auto-decision': {
|
||||||
|
if (!isActiveRun) return
|
||||||
|
setAutoPermissionDecisions(prev => {
|
||||||
|
const next = new Map(prev)
|
||||||
|
next.set(event.toolCallId, event)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
case 'ask-human-request': {
|
case 'ask-human-request': {
|
||||||
if (!isActiveRun) return
|
if (!isActiveRun) return
|
||||||
const key = event.toolCallId
|
const key = event.toolCallId
|
||||||
|
|
@ -2468,6 +2530,8 @@ function App() {
|
||||||
mentions?: FileMention[],
|
mentions?: FileMention[],
|
||||||
stagedAttachments: StagedAttachment[] = [],
|
stagedAttachments: StagedAttachment[] = [],
|
||||||
searchEnabled?: boolean,
|
searchEnabled?: boolean,
|
||||||
|
codeMode?: 'claude' | 'codex',
|
||||||
|
permissionMode?: PermissionMode,
|
||||||
) => {
|
) => {
|
||||||
if (isProcessing) return
|
if (isProcessing) return
|
||||||
|
|
||||||
|
|
@ -2507,6 +2571,7 @@ function App() {
|
||||||
const run = await window.ipc.invoke('runs:create', {
|
const run = await window.ipc.invoke('runs:create', {
|
||||||
agentId,
|
agentId,
|
||||||
...(selected ? { model: selected.model, provider: selected.provider } : {}),
|
...(selected ? { model: selected.model, provider: selected.provider } : {}),
|
||||||
|
permissionMode: permissionMode ?? 'manual',
|
||||||
})
|
})
|
||||||
currentRunId = run.id
|
currentRunId = run.id
|
||||||
newRunCreatedAt = run.createdAt
|
newRunCreatedAt = run.createdAt
|
||||||
|
|
@ -2579,6 +2644,7 @@ function App() {
|
||||||
voiceInput: pendingVoiceInputRef.current || undefined,
|
voiceInput: pendingVoiceInputRef.current || undefined,
|
||||||
voiceOutput: ttsEnabledRef.current ? ttsModeRef.current : undefined,
|
voiceOutput: ttsEnabledRef.current ? ttsModeRef.current : undefined,
|
||||||
searchEnabled: searchEnabled || undefined,
|
searchEnabled: searchEnabled || undefined,
|
||||||
|
codeMode: codeMode || undefined,
|
||||||
middlePaneContext,
|
middlePaneContext,
|
||||||
})
|
})
|
||||||
analytics.chatMessageSent({
|
analytics.chatMessageSent({
|
||||||
|
|
@ -2594,6 +2660,7 @@ function App() {
|
||||||
voiceInput: pendingVoiceInputRef.current || undefined,
|
voiceInput: pendingVoiceInputRef.current || undefined,
|
||||||
voiceOutput: ttsEnabledRef.current ? ttsModeRef.current : undefined,
|
voiceOutput: ttsEnabledRef.current ? ttsModeRef.current : undefined,
|
||||||
searchEnabled: searchEnabled || undefined,
|
searchEnabled: searchEnabled || undefined,
|
||||||
|
codeMode: codeMode || undefined,
|
||||||
middlePaneContext,
|
middlePaneContext,
|
||||||
})
|
})
|
||||||
analytics.chatMessageSent({
|
analytics.chatMessageSent({
|
||||||
|
|
@ -2680,6 +2747,26 @@ function App() {
|
||||||
}
|
}
|
||||||
}, [runId])
|
}, [runId])
|
||||||
|
|
||||||
|
// Answer a mid-run permission request from a code_agent_run coding turn. The
|
||||||
|
// pending ask lives on the tool call itself, so we optimistically clear it and
|
||||||
|
// tell main which decision the user picked (keyed by the request id).
|
||||||
|
const handleCodePermissionResponse = useCallback(async (
|
||||||
|
toolCallId: string,
|
||||||
|
requestId: string,
|
||||||
|
decision: 'allow_once' | 'allow_always' | 'reject',
|
||||||
|
) => {
|
||||||
|
setConversation(prev => prev.map(item =>
|
||||||
|
isToolCall(item) && item.id === toolCallId
|
||||||
|
? { ...item, pendingCodePermission: null }
|
||||||
|
: item
|
||||||
|
))
|
||||||
|
try {
|
||||||
|
await window.ipc.invoke('codeRun:resolvePermission', { requestId, decision })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to resolve code permission:', error)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
const handleAskHumanResponse = useCallback(async (toolCallId: string, subflow: string[], response: string) => {
|
const handleAskHumanResponse = useCallback(async (toolCallId: string, subflow: string[], response: string) => {
|
||||||
if (!runId) return
|
if (!runId) return
|
||||||
try {
|
try {
|
||||||
|
|
@ -2709,6 +2796,7 @@ function App() {
|
||||||
setPendingAskHumanRequests(new Map())
|
setPendingAskHumanRequests(new Map())
|
||||||
setAllPermissionRequests(new Map())
|
setAllPermissionRequests(new Map())
|
||||||
setPermissionResponses(new Map())
|
setPermissionResponses(new Map())
|
||||||
|
setAutoPermissionDecisions(new Map())
|
||||||
setSelectedBackgroundTask(null)
|
setSelectedBackgroundTask(null)
|
||||||
setChatViewportAnchor(activeChatTabIdRef.current, null)
|
setChatViewportAnchor(activeChatTabIdRef.current, null)
|
||||||
setChatViewStateByTab(prev => ({
|
setChatViewStateByTab(prev => ({
|
||||||
|
|
@ -2735,6 +2823,7 @@ function App() {
|
||||||
setPendingAskHumanRequests(new Map())
|
setPendingAskHumanRequests(new Map())
|
||||||
setAllPermissionRequests(new Map())
|
setAllPermissionRequests(new Map())
|
||||||
setPermissionResponses(new Map())
|
setPermissionResponses(new Map())
|
||||||
|
setAutoPermissionDecisions(new Map())
|
||||||
setChatViewportAnchor(tab.id, null)
|
setChatViewportAnchor(tab.id, null)
|
||||||
}
|
}
|
||||||
}, [loadRun, setChatViewportAnchor])
|
}, [loadRun, setChatViewportAnchor])
|
||||||
|
|
@ -2760,6 +2849,7 @@ function App() {
|
||||||
setPendingAskHumanRequests(new Map(cached.pendingAskHumanRequests))
|
setPendingAskHumanRequests(new Map(cached.pendingAskHumanRequests))
|
||||||
setAllPermissionRequests(new Map(cached.allPermissionRequests))
|
setAllPermissionRequests(new Map(cached.allPermissionRequests))
|
||||||
setPermissionResponses(new Map(cached.permissionResponses))
|
setPermissionResponses(new Map(cached.permissionResponses))
|
||||||
|
setAutoPermissionDecisions(new Map(cached.autoPermissionDecisions))
|
||||||
setIsProcessing(Boolean(resolvedRunId && processingRunIdsRef.current.has(resolvedRunId)))
|
setIsProcessing(Boolean(resolvedRunId && processingRunIdsRef.current.has(resolvedRunId)))
|
||||||
return true
|
return true
|
||||||
}, [])
|
}, [])
|
||||||
|
|
@ -3391,8 +3481,10 @@ function App() {
|
||||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false)
|
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false)
|
||||||
}, [selectedPath, isGraphOpen, isSuggestedTopicsOpen, isMeetingsOpen, isLiveNotesOpen, isBgTasksOpen, isEmailOpen, isWorkspaceOpen, isKnowledgeViewOpen, isChatHistoryOpen, dismissBrowserOverlay])
|
}, [selectedPath, isGraphOpen, isSuggestedTopicsOpen, isMeetingsOpen, isLiveNotesOpen, isBgTasksOpen, isEmailOpen, isWorkspaceOpen, isKnowledgeViewOpen, isChatHistoryOpen, dismissBrowserOverlay])
|
||||||
|
|
||||||
const handleCloseFullScreenChat = useCallback(() => {
|
const handleCloseFullScreenChat = useCallback((): boolean => {
|
||||||
|
let restored = false
|
||||||
if (expandedFrom) {
|
if (expandedFrom) {
|
||||||
|
restored = true
|
||||||
if (expandedFrom.graph) {
|
if (expandedFrom.graph) {
|
||||||
setIsGraphOpen(true)
|
setIsGraphOpen(true)
|
||||||
setIsSuggestedTopicsOpen(false)
|
setIsSuggestedTopicsOpen(false)
|
||||||
|
|
@ -3434,10 +3526,16 @@ function App() {
|
||||||
setIsSuggestedTopicsOpen(false)
|
setIsSuggestedTopicsOpen(false)
|
||||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false)
|
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false)
|
||||||
setSelectedPath(expandedFrom.path)
|
setSelectedPath(expandedFrom.path)
|
||||||
|
} else {
|
||||||
|
// expandedFrom was captured from a view this restorer doesn't track
|
||||||
|
// (e.g. Home): there's nothing to re-open, so report it and let the
|
||||||
|
// caller fall back instead of leaving a blank full-screen chat.
|
||||||
|
restored = false
|
||||||
}
|
}
|
||||||
setExpandedFrom(null)
|
setExpandedFrom(null)
|
||||||
setIsRightPaneMaximized(false)
|
setIsRightPaneMaximized(false)
|
||||||
}
|
}
|
||||||
|
return restored
|
||||||
}, [expandedFrom])
|
}, [expandedFrom])
|
||||||
|
|
||||||
const currentViewState = React.useMemo<ViewState>(() => {
|
const currentViewState = React.useMemo<ViewState>(() => {
|
||||||
|
|
@ -3447,13 +3545,13 @@ function App() {
|
||||||
if (isLiveNotesOpen) return { type: 'live-notes' }
|
if (isLiveNotesOpen) return { type: 'live-notes' }
|
||||||
if (isSuggestedTopicsOpen) return { type: 'suggested-topics' }
|
if (isSuggestedTopicsOpen) return { type: 'suggested-topics' }
|
||||||
if (isWorkspaceOpen) return { type: 'workspace', path: workspaceInitialPath ?? undefined }
|
if (isWorkspaceOpen) return { type: 'workspace', path: workspaceInitialPath ?? undefined }
|
||||||
if (isKnowledgeViewOpen) return { type: 'knowledge-view' }
|
if (isKnowledgeViewOpen) return { type: 'knowledge-view', folderPath: knowledgeViewFolderPath ?? undefined }
|
||||||
if (isChatHistoryOpen) return { type: 'chat-history' }
|
if (isChatHistoryOpen) return { type: 'chat-history' }
|
||||||
if (isHomeOpen) return { type: 'home' }
|
if (isHomeOpen) return { type: 'home' }
|
||||||
if (selectedPath) return { type: 'file', path: selectedPath }
|
if (selectedPath) return { type: 'file', path: selectedPath }
|
||||||
if (isGraphOpen) return { type: 'graph' }
|
if (isGraphOpen) return { type: 'graph' }
|
||||||
return { type: 'chat', runId }
|
return { type: 'chat', runId }
|
||||||
}, [selectedBackgroundTask, isEmailOpen, isMeetingsOpen, isLiveNotesOpen, isBgTasksOpen, isSuggestedTopicsOpen, selectedPath, isGraphOpen, isWorkspaceOpen, isKnowledgeViewOpen, isChatHistoryOpen, isHomeOpen, workspaceInitialPath, runId])
|
}, [selectedBackgroundTask, isEmailOpen, isMeetingsOpen, isLiveNotesOpen, isBgTasksOpen, isSuggestedTopicsOpen, selectedPath, isGraphOpen, isWorkspaceOpen, isKnowledgeViewOpen, knowledgeViewFolderPath, isChatHistoryOpen, isHomeOpen, workspaceInitialPath, runId])
|
||||||
|
|
||||||
const appendUnique = useCallback((stack: ViewState[], entry: ViewState) => {
|
const appendUnique = useCallback((stack: ViewState[], entry: ViewState) => {
|
||||||
const last = stack[stack.length - 1]
|
const last = stack[stack.length - 1]
|
||||||
|
|
@ -3793,6 +3891,7 @@ function App() {
|
||||||
setIsEmailOpen(false)
|
setIsEmailOpen(false)
|
||||||
setIsWorkspaceOpen(false)
|
setIsWorkspaceOpen(false)
|
||||||
setIsKnowledgeViewOpen(true)
|
setIsKnowledgeViewOpen(true)
|
||||||
|
setKnowledgeViewFolderPath(view.folderPath ?? null)
|
||||||
setIsChatHistoryOpen(false)
|
setIsChatHistoryOpen(false)
|
||||||
setIsHomeOpen(false)
|
setIsHomeOpen(false)
|
||||||
ensureKnowledgeViewFileTab()
|
ensureKnowledgeViewFileTab()
|
||||||
|
|
@ -3885,12 +3984,13 @@ function App() {
|
||||||
const pushChatToSidePane = useCallback(() => {
|
const pushChatToSidePane = useCallback(() => {
|
||||||
setIsRightPaneMaximized(false)
|
setIsRightPaneMaximized(false)
|
||||||
setIsChatSidebarOpen(true)
|
setIsChatSidebarOpen(true)
|
||||||
if (expandedFrom) {
|
// Restore the view we expanded from; if there was nothing to restore
|
||||||
handleCloseFullScreenChat()
|
// (e.g. the chat was started fresh from Home), fall back to Home so a
|
||||||
} else {
|
// single click always docks the chat instead of needing two.
|
||||||
|
if (!handleCloseFullScreenChat()) {
|
||||||
void navigateToView({ type: 'home' })
|
void navigateToView({ type: 'home' })
|
||||||
}
|
}
|
||||||
}, [expandedFrom, handleCloseFullScreenChat, navigateToView])
|
}, [handleCloseFullScreenChat, navigateToView])
|
||||||
|
|
||||||
const navigateBack = useCallback(async () => {
|
const navigateBack = useCallback(async () => {
|
||||||
const { back, forward } = historyRef.current
|
const { back, forward } = historyRef.current
|
||||||
|
|
@ -3968,53 +4068,36 @@ function App() {
|
||||||
return window.ipc.on('app:openUrl', ({ url }) => handle(url))
|
return window.ipc.on('app:openUrl', ({ url }) => handle(url))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Triggered by main when the user clicks a meeting-notes notification —
|
// Triggered by main when the user clicks a calendar-meeting notification.
|
||||||
// either the calendar-time notification (event populated) or the mic-detect
|
// Reuses the same flow as the in-app "Join meeting & take notes" button.
|
||||||
// ad-hoc notification (event=null, title=string). Both routes feed the same
|
|
||||||
// calendar-block flow which kicks off startMeetingNow().
|
|
||||||
// When `openMeeting` is true, also opens the meeting URL in the system browser.
|
// When `openMeeting` is true, also opens the meeting URL in the system browser.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return window.ipc.on('app:takeMeetingNotes', ({ event, openMeeting, title }) => {
|
return window.ipc.on('app:takeMeetingNotes', ({ event, openMeeting }) => {
|
||||||
const payload = event as
|
const e = event as {
|
||||||
| {
|
summary?: string
|
||||||
summary?: string
|
start?: { dateTime?: string; date?: string; timeZone?: string }
|
||||||
start?: { dateTime?: string; date?: string; timeZone?: string }
|
end?: { dateTime?: string; date?: string; timeZone?: string }
|
||||||
end?: { dateTime?: string; date?: string; timeZone?: string }
|
location?: string
|
||||||
location?: string
|
htmlLink?: string
|
||||||
htmlLink?: string
|
hangoutLink?: string
|
||||||
hangoutLink?: string
|
conferenceData?: { entryPoints?: Array<{ entryPointType?: string; uri?: string }> }
|
||||||
conferenceData?: { entryPoints?: Array<{ entryPointType?: string; uri?: string }> }
|
}
|
||||||
}
|
if (!e || typeof e !== 'object') return
|
||||||
| null
|
const conferenceLink = extractConferenceLink(e as Record<string, unknown>)
|
||||||
| undefined
|
if (openMeeting && conferenceLink) {
|
||||||
|
window.open(conferenceLink, '_blank')
|
||||||
if (payload && typeof payload === 'object') {
|
} else if (openMeeting) {
|
||||||
const conferenceLink = extractConferenceLink(payload as Record<string, unknown>)
|
console.warn('[take-meeting-notes] openMeeting requested but event has no conference link', e)
|
||||||
if (openMeeting && conferenceLink) {
|
}
|
||||||
window.open(conferenceLink, '_blank')
|
window.__pendingCalendarEvent = {
|
||||||
} else if (openMeeting) {
|
summary: e.summary,
|
||||||
console.warn('[take-meeting-notes] openMeeting requested but event has no conference link', payload)
|
start: e.start,
|
||||||
}
|
end: e.end,
|
||||||
window.__pendingCalendarEvent = {
|
location: e.location,
|
||||||
summary: payload.summary,
|
htmlLink: e.htmlLink,
|
||||||
start: payload.start,
|
conferenceLink,
|
||||||
end: payload.end,
|
source: 'calendar-sync',
|
||||||
location: payload.location,
|
|
||||||
htmlLink: payload.htmlLink,
|
|
||||||
conferenceLink,
|
|
||||||
source: 'calendar-sync',
|
|
||||||
}
|
|
||||||
} else if (typeof title === 'string' && title.length > 0) {
|
|
||||||
// Ad-hoc detection — no calendar event matched. Build a minimal
|
|
||||||
// pending event from the title so the meeting flow can still start.
|
|
||||||
window.__pendingCalendarEvent = {
|
|
||||||
summary: title,
|
|
||||||
source: 'meeting-detect',
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
window.dispatchEvent(new Event('calendar-block:join-meeting'))
|
window.dispatchEvent(new Event('calendar-block:join-meeting'))
|
||||||
})
|
})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
@ -4556,10 +4639,8 @@ function App() {
|
||||||
void navigateToView({ type: 'workspace', path })
|
void navigateToView({ type: 'workspace', path })
|
||||||
},
|
},
|
||||||
openKnowledgeView: () => {
|
openKnowledgeView: () => {
|
||||||
if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !isKnowledgeViewOpen && !isChatHistoryOpen && !selectedBackgroundTask) {
|
// Open in the middle pane without touching the chat sidebar — leave it
|
||||||
setIsChatSidebarOpen(false)
|
// open or closed exactly as the user had it (matches Email/Meetings).
|
||||||
setIsRightPaneMaximized(false)
|
|
||||||
}
|
|
||||||
void navigateToView({ type: 'knowledge-view' })
|
void navigateToView({ type: 'knowledge-view' })
|
||||||
},
|
},
|
||||||
createWorkspace: async (name: string): Promise<string> => {
|
createWorkspace: async (name: string): Promise<string> => {
|
||||||
|
|
@ -5041,7 +5122,11 @@ function App() {
|
||||||
}
|
}
|
||||||
}, [isGraphOpen, knowledgeFilePaths])
|
}, [isGraphOpen, knowledgeFilePaths])
|
||||||
|
|
||||||
const renderConversationItem = (item: ConversationItem, tabId: string) => {
|
const renderConversationItem = (
|
||||||
|
item: ConversationItem,
|
||||||
|
tabId: string,
|
||||||
|
options?: { autoPermissionDetail?: { decision: 'allow'; reason: string } },
|
||||||
|
) => {
|
||||||
if (isChatMessage(item)) {
|
if (isChatMessage(item)) {
|
||||||
if (item.role === 'user') {
|
if (item.role === 'user') {
|
||||||
if (item.attachments && item.attachments.length > 0) {
|
if (item.attachments && item.attachments.length > 0) {
|
||||||
|
|
@ -5099,6 +5184,21 @@ function App() {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isToolCall(item)) {
|
if (isToolCall(item)) {
|
||||||
|
if (item.name === 'code_agent_run') {
|
||||||
|
return (
|
||||||
|
<CodingRunBlock
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
open={isToolOpenForTab(tabId, item.id)}
|
||||||
|
onOpenChange={(open) => setToolOpenForTab(tabId, item.id, open)}
|
||||||
|
onPermissionDecision={(decision) => {
|
||||||
|
if (item.pendingCodePermission) {
|
||||||
|
handleCodePermissionResponse(item.id, item.pendingCodePermission.requestId, decision)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
const appActionData = getAppActionCardData(item)
|
const appActionData = getAppActionCardData(item)
|
||||||
if (appActionData) {
|
if (appActionData) {
|
||||||
return <AppActionCard key={item.id} data={appActionData} status={item.status} />
|
return <AppActionCard key={item.id} data={appActionData} status={item.status} />
|
||||||
|
|
@ -5139,6 +5239,7 @@ function App() {
|
||||||
key={item.id}
|
key={item.id}
|
||||||
open={isToolOpenForTab(tabId, item.id)}
|
open={isToolOpenForTab(tabId, item.id)}
|
||||||
onOpenChange={(open) => setToolOpenForTab(tabId, item.id, open)}
|
onOpenChange={(open) => setToolOpenForTab(tabId, item.id, open)}
|
||||||
|
autoPermissionDetail={options?.autoPermissionDetail}
|
||||||
>
|
>
|
||||||
<ToolHeader
|
<ToolHeader
|
||||||
title={toolTitle}
|
title={toolTitle}
|
||||||
|
|
@ -5181,6 +5282,7 @@ function App() {
|
||||||
pendingAskHumanRequests,
|
pendingAskHumanRequests,
|
||||||
allPermissionRequests,
|
allPermissionRequests,
|
||||||
permissionResponses,
|
permissionResponses,
|
||||||
|
autoPermissionDecisions,
|
||||||
}), [
|
}), [
|
||||||
runId,
|
runId,
|
||||||
conversation,
|
conversation,
|
||||||
|
|
@ -5188,6 +5290,7 @@ function App() {
|
||||||
pendingAskHumanRequests,
|
pendingAskHumanRequests,
|
||||||
allPermissionRequests,
|
allPermissionRequests,
|
||||||
permissionResponses,
|
permissionResponses,
|
||||||
|
autoPermissionDecisions,
|
||||||
])
|
])
|
||||||
const emptyChatTabState = React.useMemo<ChatTabViewState>(() => createEmptyChatTabViewState(), [])
|
const emptyChatTabState = React.useMemo<ChatTabViewState>(() => createEmptyChatTabViewState(), [])
|
||||||
const getChatTabStateForRender = useCallback((tabId: string): ChatTabViewState => {
|
const getChatTabStateForRender = useCallback((tabId: string): ChatTabViewState => {
|
||||||
|
|
@ -5200,6 +5303,17 @@ function App() {
|
||||||
const isRightPaneContext = Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen || isHomeOpen || isBrowserOpen)
|
const isRightPaneContext = Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen || isHomeOpen || isBrowserOpen)
|
||||||
const isRightPaneOnlyMode = isRightPaneContext && isChatSidebarOpen && isRightPaneMaximized
|
const isRightPaneOnlyMode = isRightPaneContext && isChatSidebarOpen && isRightPaneMaximized
|
||||||
const shouldCollapseLeftPane = isRightPaneOnlyMode
|
const shouldCollapseLeftPane = isRightPaneOnlyMode
|
||||||
|
const nonChatPaneStyle = React.useMemo<React.CSSProperties>(() => {
|
||||||
|
const style: React.CSSProperties = { maxWidth: insetMaxWidth }
|
||||||
|
if (!isRightPaneContext || !isChatSidebarOpen || isRightPaneMaximized) return style
|
||||||
|
if (chatPaneSize === 'chat-equal') {
|
||||||
|
return { ...style, width: 0, flex: '1 1 0' }
|
||||||
|
}
|
||||||
|
if (chatPaneSize === 'chat-bigger') {
|
||||||
|
return { ...style, width: DEFAULT_CHAT_PANE_WIDTH, flex: '0 0 auto' }
|
||||||
|
}
|
||||||
|
return style
|
||||||
|
}, [chatPaneSize, insetMaxWidth, isChatSidebarOpen, isRightPaneContext, isRightPaneMaximized])
|
||||||
// Collapsing: pin max-width to the snapshot px (no transition) for one frame so it's
|
// Collapsing: pin max-width to the snapshot px (no transition) for one frame so it's
|
||||||
// binding immediately (no flex jump), then animate to 0. Expanding goes back to 100%
|
// binding immediately (no flex jump), then animate to 0. Expanding goes back to 100%
|
||||||
// — its non-binding range lands at the end of the range, where it isn't visible.
|
// — its non-binding range lands at the end of the range, where it isn't visible.
|
||||||
|
|
@ -5277,10 +5391,11 @@ function App() {
|
||||||
<SidebarInset
|
<SidebarInset
|
||||||
className={cn(
|
className={cn(
|
||||||
"overflow-hidden! min-h-0 min-w-0",
|
"overflow-hidden! min-h-0 min-w-0",
|
||||||
|
isRightPaneContext && isChatPaneInMiddle && "order-3",
|
||||||
insetAnimateMaxWidth && "transition-[max-width] duration-200 ease-linear",
|
insetAnimateMaxWidth && "transition-[max-width] duration-200 ease-linear",
|
||||||
shouldCollapseLeftPane && "pointer-events-none select-none"
|
shouldCollapseLeftPane && "pointer-events-none select-none"
|
||||||
)}
|
)}
|
||||||
style={{ maxWidth: insetMaxWidth }}
|
style={nonChatPaneStyle}
|
||||||
aria-hidden={shouldCollapseLeftPane}
|
aria-hidden={shouldCollapseLeftPane}
|
||||||
onMouseDownCapture={() => setActiveShortcutPane('left')}
|
onMouseDownCapture={() => setActiveShortcutPane('left')}
|
||||||
onFocusCapture={() => setActiveShortcutPane('left')}
|
onFocusCapture={() => setActiveShortcutPane('left')}
|
||||||
|
|
@ -5392,7 +5507,11 @@ function App() {
|
||||||
: (viewOpen && !isChatSidebarOpen)
|
: (viewOpen && !isChatSidebarOpen)
|
||||||
? { onClick: openChatSidePane, icon: <MessageSquare className="size-5" />, label: 'Open chat' }
|
? { onClick: openChatSidePane, icon: <MessageSquare className="size-5" />, label: 'Open chat' }
|
||||||
: (viewOpen && isChatSidebarOpen && !isRightPaneMaximized)
|
: (viewOpen && isChatSidebarOpen && !isRightPaneMaximized)
|
||||||
? { onClick: toggleRightPaneMaximize, icon: <X className="size-5" />, label: 'Expand chat' }
|
? {
|
||||||
|
onClick: () => setIsChatSidebarOpen(false),
|
||||||
|
icon: isChatPaneInMiddle ? <ArrowLeft className="size-5" /> : <ArrowRight className="size-5" />,
|
||||||
|
label: 'Expand pane'
|
||||||
|
}
|
||||||
: null
|
: null
|
||||||
return (
|
return (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
|
|
@ -5491,7 +5610,11 @@ function App() {
|
||||||
remove: knowledgeActions.remove,
|
remove: knowledgeActions.remove,
|
||||||
copyPath: knowledgeActions.copyPath,
|
copyPath: knowledgeActions.copyPath,
|
||||||
revealInFileManager: knowledgeActions.revealInFileManager,
|
revealInFileManager: knowledgeActions.revealInFileManager,
|
||||||
|
createNote: knowledgeActions.createNote,
|
||||||
|
createFolder: knowledgeActions.createFolder,
|
||||||
|
onOpenInNewTab: knowledgeActions.onOpenInNewTab,
|
||||||
}}
|
}}
|
||||||
|
onNavigate={(path) => { void navigateToView({ type: 'workspace', path: path === WORKSPACE_ROOT ? undefined : path }) }}
|
||||||
onOpenNote={(path) => navigateToFile(path)}
|
onOpenNote={(path) => navigateToFile(path)}
|
||||||
onCreateWorkspace={async (name) => { await knowledgeActions.createWorkspace(name) }}
|
onCreateWorkspace={async (name) => { await knowledgeActions.createWorkspace(name) }}
|
||||||
/>
|
/>
|
||||||
|
|
@ -5509,9 +5632,11 @@ function App() {
|
||||||
revealInFileManager: knowledgeActions.revealInFileManager,
|
revealInFileManager: knowledgeActions.revealInFileManager,
|
||||||
onOpenInNewTab: knowledgeActions.onOpenInNewTab,
|
onOpenInNewTab: knowledgeActions.onOpenInNewTab,
|
||||||
}}
|
}}
|
||||||
|
folderPath={knowledgeViewFolderPath}
|
||||||
|
onNavigateFolder={(path) => { void navigateToView({ type: 'knowledge-view', folderPath: path ?? undefined }) }}
|
||||||
onOpenNote={(path) => navigateToFile(path)}
|
onOpenNote={(path) => navigateToFile(path)}
|
||||||
onOpenGraph={() => knowledgeActions.openGraph()}
|
onOpenGraph={() => knowledgeActions.openGraph()}
|
||||||
onOpenSearch={() => setIsSearchOpen(true)}
|
onOpenSearch={() => { setSearchDefaultScope('knowledge'); setIsSearchOpen(true) }}
|
||||||
onOpenBases={() => knowledgeActions.openBases()}
|
onOpenBases={() => knowledgeActions.openBases()}
|
||||||
onVoiceNoteCreated={handleVoiceNoteCreated}
|
onVoiceNoteCreated={handleVoiceNoteCreated}
|
||||||
/>
|
/>
|
||||||
|
|
@ -5701,6 +5826,10 @@ function App() {
|
||||||
<div className="flex-1 min-h-0 overflow-hidden">
|
<div className="flex-1 min-h-0 overflow-hidden">
|
||||||
<AudioFileViewer path={selectedPath} />
|
<AudioFileViewer path={selectedPath} />
|
||||||
</div>
|
</div>
|
||||||
|
) : selectedPath && getViewerType(selectedPath) === 'docx' ? (
|
||||||
|
<div className="flex-1 min-h-0 overflow-hidden">
|
||||||
|
<DocxFileViewer path={selectedPath} />
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex-1 min-h-0 overflow-hidden">
|
<div className="flex-1 min-h-0 overflow-hidden">
|
||||||
<UnsupportedFileViewer path={selectedPath} />
|
<UnsupportedFileViewer path={selectedPath} />
|
||||||
|
|
@ -5764,7 +5893,7 @@ function App() {
|
||||||
<>
|
<>
|
||||||
{groupConversationItems(
|
{groupConversationItems(
|
||||||
tabState.conversation,
|
tabState.conversation,
|
||||||
(id) => !!tabState.allPermissionRequests.get(id)
|
(id) => !!tabState.allPermissionRequests.get(id) || !!tabState.autoPermissionDecisions.get(id)
|
||||||
).map(item => {
|
).map(item => {
|
||||||
if (isToolGroup(item)) {
|
if (isToolGroup(item)) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -5776,24 +5905,44 @@ function App() {
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
const rendered = renderConversationItem(item, tab.id)
|
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)) {
|
if (isToolCall(item)) {
|
||||||
|
const deniedAutoDecision = autoDecision?.decision === 'deny' ? autoDecision : null
|
||||||
const permRequest = tabState.allPermissionRequests.get(item.id)
|
const permRequest = tabState.allPermissionRequests.get(item.id)
|
||||||
if (permRequest) {
|
if (deniedAutoDecision || permRequest) {
|
||||||
const response = tabState.permissionResponses.get(item.id) || null
|
const response = tabState.permissionResponses.get(item.id) || null
|
||||||
return (
|
return (
|
||||||
<React.Fragment key={item.id}>
|
<React.Fragment key={item.id}>
|
||||||
|
{deniedAutoDecision && (
|
||||||
|
<AutoPermissionDecision
|
||||||
|
toolCall={deniedAutoDecision.toolCall}
|
||||||
|
permission={deniedAutoDecision.permission}
|
||||||
|
decision={deniedAutoDecision.decision}
|
||||||
|
reason={deniedAutoDecision.reason}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{permRequest && (
|
||||||
|
<PermissionRequest
|
||||||
|
toolCall={permRequest.toolCall}
|
||||||
|
permission={permRequest.permission}
|
||||||
|
onApprove={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve')}
|
||||||
|
onApproveSession={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'session')}
|
||||||
|
onApproveAlways={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'always')}
|
||||||
|
onDeny={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'deny')}
|
||||||
|
isProcessing={isActive && isProcessing}
|
||||||
|
response={response}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{rendered}
|
{rendered}
|
||||||
<PermissionRequest
|
|
||||||
toolCall={permRequest.toolCall}
|
|
||||||
permission={permRequest.permission}
|
|
||||||
onApprove={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve')}
|
|
||||||
onApproveSession={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'session')}
|
|
||||||
onApproveAlways={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'always')}
|
|
||||||
onDeny={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'deny')}
|
|
||||||
isProcessing={isActive && isProcessing}
|
|
||||||
response={response}
|
|
||||||
/>
|
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -5805,6 +5954,7 @@ function App() {
|
||||||
<AskHumanRequest
|
<AskHumanRequest
|
||||||
key={request.toolCallId}
|
key={request.toolCallId}
|
||||||
query={request.query}
|
query={request.query}
|
||||||
|
options={request.options}
|
||||||
onResponse={(response) => handleAskHumanResponse(request.toolCallId, request.subflow, response)}
|
onResponse={(response) => handleAskHumanResponse(request.toolCallId, request.subflow, response)}
|
||||||
isProcessing={isActive && isProcessing}
|
isProcessing={isActive && isProcessing}
|
||||||
/>
|
/>
|
||||||
|
|
@ -5894,10 +6044,13 @@ function App() {
|
||||||
)}
|
)}
|
||||||
</SidebarInset>
|
</SidebarInset>
|
||||||
|
|
||||||
{/* Chat sidebar - shown when viewing files/graph */}
|
{/* Chat pane - shown when viewing files/graph */}
|
||||||
{isRightPaneContext && (
|
{isRightPaneContext && (
|
||||||
<ChatSidebar
|
<ChatSidebar
|
||||||
defaultWidth={460}
|
placement={chatPanePlacement}
|
||||||
|
paneSize={chatPaneSize}
|
||||||
|
className={isChatPaneInMiddle ? "order-2" : undefined}
|
||||||
|
defaultWidth={DEFAULT_CHAT_PANE_WIDTH}
|
||||||
isOpen={isChatSidebarOpen}
|
isOpen={isChatSidebarOpen}
|
||||||
isMaximized={isRightPaneMaximized}
|
isMaximized={isRightPaneMaximized}
|
||||||
chatTabs={chatTabs}
|
chatTabs={chatTabs}
|
||||||
|
|
@ -5916,7 +6069,6 @@ function App() {
|
||||||
}}
|
}}
|
||||||
onOpenChatHistory={() => void navigateToView({ type: 'chat-history' })}
|
onOpenChatHistory={() => void navigateToView({ type: 'chat-history' })}
|
||||||
onOpenFullScreen={toggleRightPaneMaximize}
|
onOpenFullScreen={toggleRightPaneMaximize}
|
||||||
onCloseChat={() => { setIsRightPaneMaximized(false); setIsChatSidebarOpen(false) }}
|
|
||||||
conversation={conversation}
|
conversation={conversation}
|
||||||
currentAssistantMessage={currentAssistantMessage}
|
currentAssistantMessage={currentAssistantMessage}
|
||||||
chatTabStates={chatViewStateByTab}
|
chatTabStates={chatViewStateByTab}
|
||||||
|
|
@ -5945,6 +6097,7 @@ function App() {
|
||||||
pendingAskHumanRequests={pendingAskHumanRequests}
|
pendingAskHumanRequests={pendingAskHumanRequests}
|
||||||
allPermissionRequests={allPermissionRequests}
|
allPermissionRequests={allPermissionRequests}
|
||||||
permissionResponses={permissionResponses}
|
permissionResponses={permissionResponses}
|
||||||
|
autoPermissionDecisions={autoPermissionDecisions}
|
||||||
onPermissionResponse={handlePermissionResponse}
|
onPermissionResponse={handlePermissionResponse}
|
||||||
onAskHumanResponse={handleAskHumanResponse}
|
onAskHumanResponse={handleAskHumanResponse}
|
||||||
isToolOpenForTab={isToolOpenForTab}
|
isToolOpenForTab={isToolOpenForTab}
|
||||||
|
|
@ -5975,7 +6128,8 @@ function App() {
|
||||||
</div>
|
</div>
|
||||||
<CommandPalette
|
<CommandPalette
|
||||||
open={isSearchOpen}
|
open={isSearchOpen}
|
||||||
onOpenChange={setIsSearchOpen}
|
onOpenChange={(o) => { setIsSearchOpen(o); if (!o) setSearchDefaultScope(undefined) }}
|
||||||
|
defaultScope={searchDefaultScope}
|
||||||
onSelectFile={navigateToFile}
|
onSelectFile={navigateToFile}
|
||||||
onSelectRun={(id) => { void navigateToView({ type: 'chat', runId: id }) }}
|
onSelectRun={(id) => { void navigateToView({ type: 'chat', runId: id }) }}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import { useState, useRef, useEffect } from "react";
|
||||||
|
|
||||||
export type AskHumanRequestProps = ComponentProps<"div"> & {
|
export type AskHumanRequestProps = ComponentProps<"div"> & {
|
||||||
query: string;
|
query: string;
|
||||||
|
options?: string[];
|
||||||
onResponse: (response: string) => void;
|
onResponse: (response: string) => void;
|
||||||
isProcessing?: boolean;
|
isProcessing?: boolean;
|
||||||
};
|
};
|
||||||
|
|
@ -16,17 +17,21 @@ export type AskHumanRequestProps = ComponentProps<"div"> & {
|
||||||
export const AskHumanRequest = ({
|
export const AskHumanRequest = ({
|
||||||
className,
|
className,
|
||||||
query,
|
query,
|
||||||
|
options,
|
||||||
onResponse,
|
onResponse,
|
||||||
isProcessing = false,
|
isProcessing = false,
|
||||||
...props
|
...props
|
||||||
}: AskHumanRequestProps) => {
|
}: AskHumanRequestProps) => {
|
||||||
const [response, setResponse] = useState("");
|
const [response, setResponse] = useState("");
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
const hasOptions = Array.isArray(options) && options.length > 0;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Auto-focus the textarea when component mounts
|
// Auto-focus the textarea when in free-text mode; nothing to focus for buttons.
|
||||||
textareaRef.current?.focus();
|
if (!hasOptions) {
|
||||||
}, []);
|
textareaRef.current?.focus();
|
||||||
|
}
|
||||||
|
}, [hasOptions]);
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
const trimmed = response.trim();
|
const trimmed = response.trim();
|
||||||
|
|
@ -36,6 +41,11 @@ export const AskHumanRequest = ({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleOptionClick = (option: string) => {
|
||||||
|
if (isProcessing) return;
|
||||||
|
onResponse(option);
|
||||||
|
};
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
if (e.key === "Enter" && !e.shiftKey) {
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
@ -65,30 +75,47 @@ export const AskHumanRequest = ({
|
||||||
{query}
|
{query}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
{hasOptions ? (
|
||||||
<Textarea
|
<div className="flex flex-wrap gap-2">
|
||||||
ref={textareaRef}
|
{options!.map((option) => (
|
||||||
value={response}
|
<Button
|
||||||
onChange={(e) => setResponse(e.target.value)}
|
key={option}
|
||||||
onKeyDown={handleKeyDown}
|
variant="outline"
|
||||||
placeholder="Type your response..."
|
size="sm"
|
||||||
disabled={isProcessing}
|
onClick={() => handleOptionClick(option)}
|
||||||
rows={3}
|
disabled={isProcessing}
|
||||||
className="resize-none"
|
className="bg-background"
|
||||||
/>
|
>
|
||||||
<div className="flex justify-end">
|
{option}
|
||||||
<Button
|
</Button>
|
||||||
variant="default"
|
))}
|
||||||
size="sm"
|
|
||||||
onClick={handleSubmit}
|
|
||||||
disabled={!canSubmit}
|
|
||||||
className="gap-2"
|
|
||||||
>
|
|
||||||
<ArrowUpIcon className="size-4" />
|
|
||||||
Send Response
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
value={response}
|
||||||
|
onChange={(e) => setResponse(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder="Type your response..."
|
||||||
|
disabled={isProcessing}
|
||||||
|
rows={3}
|
||||||
|
className="resize-none"
|
||||||
|
/>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={!canSubmit}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<ArrowUpIcon className="size-4" />
|
||||||
|
Send Response
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,100 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { CheckCircle2Icon, ShieldAlertIcon, Terminal } from "lucide-react";
|
||||||
|
import type { ComponentProps } from "react";
|
||||||
|
import { ToolCallPart } from "@x/shared/dist/message.js";
|
||||||
|
import { ToolPermissionMetadata } from "@x/shared/dist/runs.js";
|
||||||
|
import z from "zod";
|
||||||
|
|
||||||
|
export type AutoPermissionDecisionProps = ComponentProps<"div"> & {
|
||||||
|
toolCall: z.infer<typeof ToolCallPart>;
|
||||||
|
decision: "allow" | "deny";
|
||||||
|
reason: string;
|
||||||
|
permission?: z.infer<typeof ToolPermissionMetadata>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const fileActionLabels: Record<string, string> = {
|
||||||
|
read: "Read file",
|
||||||
|
list: "List folder",
|
||||||
|
search: "Search files",
|
||||||
|
write: "Write files",
|
||||||
|
delete: "Delete path",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AutoPermissionDecision({
|
||||||
|
className,
|
||||||
|
toolCall,
|
||||||
|
decision,
|
||||||
|
reason,
|
||||||
|
permission,
|
||||||
|
...props
|
||||||
|
}: AutoPermissionDecisionProps) {
|
||||||
|
const command = permission?.kind === "command" || toolCall.toolName === "executeCommand"
|
||||||
|
? (typeof toolCall.arguments === "object" && toolCall.arguments !== null && "command" in toolCall.arguments
|
||||||
|
? String(toolCall.arguments.command)
|
||||||
|
: JSON.stringify(toolCall.arguments))
|
||||||
|
: null;
|
||||||
|
const filePermission = permission?.kind === "file" ? permission : null;
|
||||||
|
const allowed = decision === "allow";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"not-prose mb-4 w-full rounded-md border",
|
||||||
|
allowed
|
||||||
|
? "border-green-500/50 bg-green-50/80 dark:border-green-500/35 dark:bg-green-950/30"
|
||||||
|
: "border-[#fa2525]/60 bg-[#fa2525]/15 dark:border-[#fa2525]/50 dark:bg-[#fa2525]/20",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div className="space-y-3 p-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
{allowed ? (
|
||||||
|
<CheckCircle2Icon className="mt-0.5 size-5 shrink-0 text-green-600 dark:text-green-400" />
|
||||||
|
) : (
|
||||||
|
<ShieldAlertIcon className="mt-0.5 size-5 shrink-0 text-destructive" />
|
||||||
|
)}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<h3 className="text-sm font-semibold text-foreground">
|
||||||
|
{allowed ? "Auto Allowed" : "Auto Denied"}
|
||||||
|
</h3>
|
||||||
|
<Badge variant="secondary" className="bg-secondary text-foreground">
|
||||||
|
<Terminal className="mr-1 size-3" />
|
||||||
|
{toolCall.toolName}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">{reason}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{command && (
|
||||||
|
<div className="rounded-md border bg-background/50 p-3">
|
||||||
|
<p className="mb-1.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">Command</p>
|
||||||
|
<pre className="whitespace-pre-wrap break-all font-mono text-xs text-foreground">{command}</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{filePermission && (
|
||||||
|
<div className="space-y-3 rounded-md border bg-background/50 p-3">
|
||||||
|
<div>
|
||||||
|
<p className="mb-1.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">Action</p>
|
||||||
|
<p className="text-xs font-medium text-foreground">
|
||||||
|
{fileActionLabels[filePermission.operation] ?? filePermission.operation}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="mb-1.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
|
Path{filePermission.paths.length === 1 ? "" : "s"}
|
||||||
|
</p>
|
||||||
|
<pre className="whitespace-pre-wrap break-all font-mono text-xs text-foreground">
|
||||||
|
{filePermission.paths.join("\n")}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
|
|
@ -9,8 +8,8 @@ import {
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { AlertTriangleIcon, CheckCircleIcon, CheckIcon, ChevronDownIcon, XCircleIcon, XIcon } from "lucide-react";
|
import { AlertTriangleIcon, CheckIcon, ChevronDownIcon, XIcon } from "lucide-react";
|
||||||
import type { ComponentProps } from "react";
|
import { useState, type ComponentProps } from "react";
|
||||||
import { ToolCallPart } from "@x/shared/dist/message.js";
|
import { ToolCallPart } from "@x/shared/dist/message.js";
|
||||||
import { ToolPermissionMetadata } from "@x/shared/dist/runs.js";
|
import { ToolPermissionMetadata } from "@x/shared/dist/runs.js";
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
|
|
@ -57,14 +56,19 @@ export const PermissionRequest = ({
|
||||||
const isResponded = response !== null;
|
const isResponded = response !== null;
|
||||||
const isApproved = response === 'approve';
|
const isApproved = response === 'approve';
|
||||||
|
|
||||||
|
// Once a response is chosen, collapse the details to just the header.
|
||||||
|
// Users can click the header to expand them again.
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const showDetails = !isResponded || expanded;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"not-prose mb-4 w-full rounded-md border",
|
"not-prose mb-4 w-full rounded-md border",
|
||||||
isResponded
|
isResponded
|
||||||
? isApproved
|
? isApproved
|
||||||
? "border-green-500/50 bg-green-50/50 dark:bg-green-950/20"
|
? "border-green-500/60 bg-green-200/80 dark:border-green-500/40 dark:bg-green-900/40"
|
||||||
: "border-red-500/50 bg-red-50/50 dark:bg-red-950/20"
|
: "border-[#fa2525]/70 bg-[#fa2525]/30 dark:border-[#fa2525]/60 dark:bg-[#fa2525]/30"
|
||||||
: "border-amber-500/50 bg-amber-50/50 dark:bg-amber-950/20",
|
: "border-amber-500/50 bg-amber-50/50 dark:bg-amber-950/20",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
|
|
@ -72,17 +76,14 @@ export const PermissionRequest = ({
|
||||||
>
|
>
|
||||||
<div className="p-4 space-y-4">
|
<div className="p-4 space-y-4">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
{isResponded ? (
|
{!isResponded && (
|
||||||
isApproved ? (
|
|
||||||
<CheckCircleIcon className="size-5 text-green-600 dark:text-green-500 shrink-0 mt-0.5" />
|
|
||||||
) : (
|
|
||||||
<XCircleIcon className="size-5 text-red-600 dark:text-red-500 shrink-0 mt-0.5" />
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<AlertTriangleIcon className="size-5 text-amber-600 dark:text-amber-500 shrink-0 mt-0.5" />
|
<AlertTriangleIcon className="size-5 text-amber-600 dark:text-amber-500 shrink-0 mt-0.5" />
|
||||||
)}
|
)}
|
||||||
<div className="flex-1 space-y-2">
|
<div className="flex-1 space-y-2">
|
||||||
<div className="flex items-center gap-2">
|
<div
|
||||||
|
className={cn("flex items-center gap-2", isResponded && "cursor-pointer select-none")}
|
||||||
|
onClick={isResponded ? () => setExpanded((v) => !v) : undefined}
|
||||||
|
>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<h3 className="font-semibold text-sm text-foreground">
|
<h3 className="font-semibold text-sm text-foreground">
|
||||||
{isResponded ? (isApproved ? "Permission Granted" : "Permission Denied") : "Permission Required"}
|
{isResponded ? (isApproved ? "Permission Granted" : "Permission Denied") : "Permission Required"}
|
||||||
|
|
@ -92,30 +93,15 @@ export const PermissionRequest = ({
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{isResponded && (
|
{isResponded && (
|
||||||
<Badge
|
<ChevronDownIcon
|
||||||
variant="secondary"
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"shrink-0",
|
"size-4 shrink-0 text-muted-foreground transition-transform",
|
||||||
isApproved
|
expanded ? "rotate-180" : "rotate-0"
|
||||||
? "bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-400"
|
|
||||||
: "bg-red-100 text-red-700 dark:bg-red-900/50 dark:text-red-400"
|
|
||||||
)}
|
)}
|
||||||
>
|
/>
|
||||||
{isApproved ? (
|
|
||||||
<>
|
|
||||||
<CheckIcon className="size-3 mr-1" />
|
|
||||||
Approved
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<XIcon className="size-3 mr-1" />
|
|
||||||
Denied
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Badge>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{command && (
|
{showDetails && command && (
|
||||||
<div className="rounded-md border bg-background/50 p-3 mt-3">
|
<div className="rounded-md border bg-background/50 p-3 mt-3">
|
||||||
<p className="text-xs font-medium text-muted-foreground mb-1.5 uppercase tracking-wide">
|
<p className="text-xs font-medium text-muted-foreground mb-1.5 uppercase tracking-wide">
|
||||||
Command
|
Command
|
||||||
|
|
@ -125,7 +111,7 @@ export const PermissionRequest = ({
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{filePermission && (
|
{showDetails && filePermission && (
|
||||||
<div className="rounded-md border bg-background/50 p-3 mt-3 space-y-3">
|
<div className="rounded-md border bg-background/50 p-3 mt-3 space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-medium text-muted-foreground mb-1.5 uppercase tracking-wide">
|
<p className="text-xs font-medium text-muted-foreground mb-1.5 uppercase tracking-wide">
|
||||||
|
|
@ -153,7 +139,7 @@ export const PermissionRequest = ({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!command && !filePermission && toolCall.arguments && (
|
{showDetails && !command && !filePermission && toolCall.arguments && (
|
||||||
<div className="rounded-md border bg-background/50 p-3 mt-3">
|
<div className="rounded-md border bg-background/50 p-3 mt-3">
|
||||||
<p className="text-xs font-medium text-muted-foreground mb-1.5 uppercase tracking-wide">
|
<p className="text-xs font-medium text-muted-foreground mb-1.5 uppercase tracking-wide">
|
||||||
Arguments
|
Arguments
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,28 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import {
|
import {
|
||||||
Collapsible,
|
Collapsible,
|
||||||
CollapsibleContent,
|
CollapsibleContent,
|
||||||
CollapsibleTrigger,
|
CollapsibleTrigger,
|
||||||
} from "@/components/ui/collapsible";
|
} from "@/components/ui/collapsible";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { ToolUIPart } from "ai";
|
import type { ToolUIPart } from "ai";
|
||||||
import {
|
import {
|
||||||
CheckCircleIcon,
|
|
||||||
ChevronDownIcon,
|
ChevronDownIcon,
|
||||||
CircleIcon,
|
CircleCheck,
|
||||||
ClockIcon,
|
LoaderIcon,
|
||||||
WrenchIcon,
|
ShieldCheckIcon,
|
||||||
XCircleIcon,
|
XCircleIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { type ComponentProps, type ReactNode, isValidElement, useState } from "react";
|
import { type ComponentProps, type ReactNode, isValidElement, useState } from "react";
|
||||||
import { AnimatePresence, motion } from "motion/react";
|
import { AnimatePresence, motion } from "motion/react";
|
||||||
import type { ToolCall, ToolGroup as ToolGroupType } from "@/lib/chat-conversation";
|
import type { ToolCall, ToolGroup as ToolGroupType } from "@/lib/chat-conversation";
|
||||||
import { getToolDisplayName, getToolGroupSummary, toToolState } from "@/lib/chat-conversation";
|
import { getToolActionsSummary, getToolDisplayName, getToolGroupSummary, toToolState } from "@/lib/chat-conversation";
|
||||||
|
|
||||||
const formatToolValue = (value: unknown) => {
|
const formatToolValue = (value: unknown) => {
|
||||||
if (typeof value === "string") return value;
|
if (typeof value === "string") return value;
|
||||||
|
|
@ -48,51 +51,68 @@ const ToolCode = ({
|
||||||
</pre>
|
</pre>
|
||||||
);
|
);
|
||||||
|
|
||||||
export type ToolProps = ComponentProps<typeof Collapsible>;
|
export type ToolAutoPermissionDetail = {
|
||||||
|
decision: "allow";
|
||||||
|
reason: string;
|
||||||
|
};
|
||||||
|
|
||||||
export const Tool = ({ className, ...props }: ToolProps) => (
|
export type ToolProps = ComponentProps<typeof Collapsible> & {
|
||||||
<Collapsible
|
autoPermissionDetail?: ToolAutoPermissionDetail;
|
||||||
className={cn("not-prose mb-4 w-full rounded-md border", className)}
|
};
|
||||||
{...props}
|
|
||||||
/>
|
export const Tool = ({ className, children, autoPermissionDetail, ...props }: ToolProps) => {
|
||||||
);
|
const toolCard = (
|
||||||
|
<Collapsible
|
||||||
|
className={cn(
|
||||||
|
autoPermissionDetail
|
||||||
|
? "w-full rounded-[28px] border bg-[var(--card-surface)] transition-colors duration-150 ease-out hover:border-foreground/30"
|
||||||
|
: "not-prose mb-4 w-full rounded-[28px] border bg-[var(--card-surface)] transition-colors duration-150 ease-out hover:border-foreground/30",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Collapsible>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!autoPermissionDetail) return toolCard;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="not-prose mb-4 w-full">
|
||||||
|
{toolCard}
|
||||||
|
<div className="mt-1 flex justify-end px-3">
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<span className="inline-flex cursor-help items-center gap-1 text-[11px] text-muted-foreground/70">
|
||||||
|
<ShieldCheckIcon className="size-3 text-muted-foreground/70" />
|
||||||
|
Auto-approved
|
||||||
|
</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom" align="end" className="max-w-sm">
|
||||||
|
{autoPermissionDetail.reason}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export type ToolHeaderProps = {
|
export type ToolHeaderProps = {
|
||||||
title?: string;
|
title?: string;
|
||||||
type: ToolUIPart["type"];
|
type: ToolUIPart["type"];
|
||||||
state: ToolUIPart["state"];
|
state: ToolUIPart["state"];
|
||||||
className?: string;
|
className?: string;
|
||||||
|
/** Hide the leading status icon (used for child rows inside a tool group). */
|
||||||
|
hideLeadIcon?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getStatusBadge = (status: ToolUIPart["state"]) => {
|
// Lead icon shown to the left of the tool label: spinner while running, a
|
||||||
const labels: Record<ToolUIPart["state"], string> = {
|
// green check when done, a red cross on error. Shared by ToolHeader (single
|
||||||
"input-streaming": "Pending",
|
// tools) and the tool-call group.
|
||||||
"input-available": "Running",
|
const getLeadIcon = (state: ToolUIPart["state"]): ReactNode => {
|
||||||
// @ts-expect-error state only available in AI SDK v6
|
if (state === "output-available") return <CircleCheck className="size-4 shrink-0 text-green-600" />;
|
||||||
"approval-requested": "Awaiting Approval",
|
if (state === "output-error") return <XCircleIcon className="size-4 shrink-0 text-red-600" />;
|
||||||
"approval-responded": "Responded",
|
return <LoaderIcon className="size-4 shrink-0 animate-spin text-muted-foreground" />;
|
||||||
"output-available": "Completed",
|
|
||||||
"output-error": "Error",
|
|
||||||
"output-denied": "Denied",
|
|
||||||
};
|
|
||||||
|
|
||||||
const icons: Record<ToolUIPart["state"], ReactNode> = {
|
|
||||||
"input-streaming": <CircleIcon className="size-4" />,
|
|
||||||
"input-available": <ClockIcon className="size-4 animate-pulse" />,
|
|
||||||
// @ts-expect-error state only available in AI SDK v6
|
|
||||||
"approval-requested": <ClockIcon className="size-4 text-yellow-600" />,
|
|
||||||
"approval-responded": <CheckCircleIcon className="size-4 text-blue-600" />,
|
|
||||||
"output-available": <CheckCircleIcon className="size-4 text-green-600" />,
|
|
||||||
"output-error": <XCircleIcon className="size-4 text-red-600" />,
|
|
||||||
"output-denied": <XCircleIcon className="size-4 text-orange-600" />,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Badge className="gap-1.5 rounded-full text-xs" variant="secondary">
|
|
||||||
{icons[status]}
|
|
||||||
{labels[status]}
|
|
||||||
</Badge>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ToolHeader = ({
|
export const ToolHeader = ({
|
||||||
|
|
@ -100,6 +120,7 @@ export const ToolHeader = ({
|
||||||
title,
|
title,
|
||||||
type,
|
type,
|
||||||
state,
|
state,
|
||||||
|
hideLeadIcon,
|
||||||
...props
|
...props
|
||||||
}: ToolHeaderProps) => {
|
}: ToolHeaderProps) => {
|
||||||
const displayTitle = title ?? type.split("-").slice(1).join("-")
|
const displayTitle = title ?? type.split("-").slice(1).join("-")
|
||||||
|
|
@ -107,13 +128,13 @@ export const ToolHeader = ({
|
||||||
return (
|
return (
|
||||||
<CollapsibleTrigger
|
<CollapsibleTrigger
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex w-full items-center justify-between gap-4 p-3",
|
"group flex w-full cursor-pointer items-center justify-between gap-3 px-4 py-2.5",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||||
<WrenchIcon className="size-4 shrink-0 text-muted-foreground" />
|
{!hideLeadIcon && getLeadIcon(state)}
|
||||||
<span
|
<span
|
||||||
className="min-w-0 flex-1 truncate text-left font-medium text-sm"
|
className="min-w-0 flex-1 truncate text-left font-medium text-sm"
|
||||||
title={displayTitle}
|
title={displayTitle}
|
||||||
|
|
@ -121,10 +142,7 @@ export const ToolHeader = ({
|
||||||
{displayTitle}
|
{displayTitle}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 items-center gap-3">
|
<ChevronDownIcon className="size-4 shrink-0 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
|
||||||
{getStatusBadge(state)}
|
|
||||||
<ChevronDownIcon className="size-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
|
|
||||||
</div>
|
|
||||||
</CollapsibleTrigger>
|
</CollapsibleTrigger>
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
@ -134,7 +152,7 @@ export type ToolContentProps = ComponentProps<typeof CollapsibleContent>;
|
||||||
export const ToolContent = ({ className, ...props }: ToolContentProps) => (
|
export const ToolContent = ({ className, ...props }: ToolContentProps) => (
|
||||||
<CollapsibleContent
|
<CollapsibleContent
|
||||||
className={cn(
|
className={cn(
|
||||||
"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",
|
"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]",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
@ -247,41 +265,48 @@ export const ToolGroupComponent = ({ group, isToolOpen, onToolOpenChange }: Tool
|
||||||
const isCompleted = state === 'output-available' || state === 'output-error'
|
const isCompleted = state === 'output-available' || state === 'output-error'
|
||||||
const runningTool = group.items.find(t => t.status === 'running' || t.status === 'pending')
|
const runningTool = group.items.find(t => t.status === 'running' || t.status === 'pending')
|
||||||
const currentTool = runningTool ?? group.items[group.items.length - 1]
|
const currentTool = runningTool ?? group.items[group.items.length - 1]
|
||||||
const summary = isCompleted
|
const toolCount = group.items.length
|
||||||
? `Ran ${group.items.length} tool${group.items.length !== 1 ? 's' : ''}`
|
const ranLabel = `Ran ${toolCount} tool${toolCount !== 1 ? 's' : ''}`
|
||||||
|
const actions = isCompleted ? getToolActionsSummary(group.items) : ''
|
||||||
|
// Plain string used as the AnimatePresence key + tooltip; the rendered node
|
||||||
|
// shows the action summary in a lighter gray than the "Ran N tools" prefix.
|
||||||
|
const summaryText = isCompleted
|
||||||
|
? `${ranLabel} · ${actions}`
|
||||||
: currentTool ? getToolDisplayName(currentTool) : getToolGroupSummary(group.items)
|
: 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 (
|
return (
|
||||||
<Collapsible
|
<Collapsible
|
||||||
open={open}
|
open={open}
|
||||||
onOpenChange={setOpen}
|
onOpenChange={setOpen}
|
||||||
className="not-prose mb-4 w-full rounded-md border"
|
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 items-center justify-between gap-4 p-3">
|
<CollapsibleTrigger className="flex w-full cursor-pointer items-center justify-between gap-3 px-4 py-2.5">
|
||||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||||
<WrenchIcon className="size-4 shrink-0 text-muted-foreground" />
|
{leadIcon}
|
||||||
<div className="relative min-w-0 flex-1 overflow-hidden" style={{ height: '1.25rem' }}>
|
<div className="relative min-w-0 flex-1 overflow-hidden" style={{ height: '1.25rem' }}>
|
||||||
<AnimatePresence mode="popLayout" initial={false}>
|
<AnimatePresence mode="popLayout" initial={false}>
|
||||||
<motion.span
|
<motion.span
|
||||||
key={summary}
|
key={summaryText}
|
||||||
initial={{ opacity: 0, y: 10 }}
|
initial={{ opacity: 0, y: 10 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
exit={{ opacity: 0, y: -10 }}
|
exit={{ opacity: 0, y: -10 }}
|
||||||
transition={{ duration: 0.18, ease: 'easeOut' }}
|
transition={{ duration: 0.18, ease: 'easeOut' }}
|
||||||
className="absolute inset-0 truncate text-left font-medium text-sm leading-5"
|
className="absolute inset-0 truncate text-left font-medium text-sm leading-5"
|
||||||
title={summary}
|
title={summaryText}
|
||||||
>
|
>
|
||||||
{summary}
|
{summaryNode}
|
||||||
</motion.span>
|
</motion.span>
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 items-center gap-3">
|
<ChevronDownIcon className={cn("size-4 shrink-0 text-muted-foreground transition-transform", open && "rotate-180")} />
|
||||||
{getStatusBadge(state)}
|
|
||||||
<ChevronDownIcon className={cn("size-4 text-muted-foreground transition-transform", open && "rotate-180")} />
|
|
||||||
</div>
|
|
||||||
</CollapsibleTrigger>
|
</CollapsibleTrigger>
|
||||||
<CollapsibleContent className="border-t">
|
<CollapsibleContent className="overflow-hidden data-[state=open]:animate-[collapsible-down_0.09s_ease-out] data-[state=closed]:animate-[collapsible-up_0.08s_ease-in]">
|
||||||
<div className="flex flex-col gap-2 p-2">
|
<div className="flex flex-col gap-2 p-2">
|
||||||
{group.items.map((tool) => {
|
{group.items.map((tool) => {
|
||||||
const toolState = toToolState(tool.status)
|
const toolState = toToolState(tool.status)
|
||||||
|
|
@ -291,12 +316,14 @@ export const ToolGroupComponent = ({ group, isToolOpen, onToolOpenChange }: Tool
|
||||||
key={tool.id}
|
key={tool.id}
|
||||||
open={isOpen}
|
open={isOpen}
|
||||||
onOpenChange={(o) => onToolOpenChange(tool.id, o)}
|
onOpenChange={(o) => onToolOpenChange(tool.id, o)}
|
||||||
className="mb-0 border-border/60"
|
className="mb-0 rounded-[20px] border-border/60 bg-transparent hover:border-border/60"
|
||||||
>
|
>
|
||||||
<ToolHeader
|
<ToolHeader
|
||||||
title={getToolDisplayName(tool)}
|
title={getToolDisplayName(tool)}
|
||||||
type={`tool-${tool.name}`}
|
type={`tool-${tool.name}`}
|
||||||
state={toolState}
|
state={toolState}
|
||||||
|
className="text-muted-foreground"
|
||||||
|
hideLeadIcon
|
||||||
/>
|
/>
|
||||||
<ToolContent>
|
<ToolContent>
|
||||||
<ToolTabbedContent
|
<ToolTabbedContent
|
||||||
|
|
|
||||||
|
|
@ -5,12 +5,14 @@ import {
|
||||||
CollapsibleContent,
|
CollapsibleContent,
|
||||||
CollapsibleTrigger,
|
CollapsibleTrigger,
|
||||||
} from "@/components/ui/collapsible";
|
} from "@/components/ui/collapsible";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
CheckCircleIcon,
|
|
||||||
ChevronDownIcon,
|
ChevronDownIcon,
|
||||||
GlobeIcon,
|
GlobeIcon,
|
||||||
LoaderIcon,
|
LoaderIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { AnimatePresence, motion } from "motion/react";
|
||||||
|
|
||||||
interface WebSearchResultProps {
|
interface WebSearchResultProps {
|
||||||
query: string;
|
query: string;
|
||||||
|
|
@ -19,39 +21,219 @@ interface WebSearchResultProps {
|
||||||
title?: string;
|
title?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// How long each fetched website stays on the rolling header before the
|
||||||
|
// next one slides in. Kept slow enough to read the domain + title.
|
||||||
|
const ROLL_INTERVAL_MS = 700;
|
||||||
|
|
||||||
|
// How many favicons to show in the settled stack before the rest collapse
|
||||||
|
// into a "+N" chip. The text names this many domains too, so the chip count
|
||||||
|
// (total - MAX_STACK) lines up with the "and N others" in the summary.
|
||||||
|
const MAX_STACK = 3;
|
||||||
|
|
||||||
function getDomain(url: string): string {
|
function getDomain(url: string): string {
|
||||||
try {
|
try {
|
||||||
return new URL(url).hostname;
|
return new URL(url).hostname.replace(/^www\./, "");
|
||||||
} catch {
|
} catch {
|
||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function faviconUrl(domain: string, size = 32): string {
|
||||||
|
return `https://www.google.com/s2/favicons?domain=${domain}&sz=${size}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collapse the result list into unique domains, preserving order.
|
||||||
|
function uniqueDomains(results: WebSearchResultProps["results"]): string[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const result of results) {
|
||||||
|
const domain = getDomain(result.url);
|
||||||
|
if (seen.has(domain)) continue;
|
||||||
|
seen.add(domain);
|
||||||
|
out.push(domain);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Summary with text hierarchy: "Searched" + "and N others" are secondary
|
||||||
|
// weight/color, the domain names are primary text at medium weight.
|
||||||
|
function buildSearchedSummary(domains: string[]): React.ReactNode {
|
||||||
|
const muted = "font-normal text-muted-foreground";
|
||||||
|
const name = (d: string) => <span className="font-medium text-foreground">{d}</span>;
|
||||||
|
if (domains.length === 1) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<span className={muted}>Searched </span>
|
||||||
|
{name(domains[0])}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (domains.length === 2) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<span className={muted}>Searched </span>
|
||||||
|
{name(domains[0])}
|
||||||
|
<span className={muted}> and </span>
|
||||||
|
{name(domains[1])}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const others = domains.length - 2;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<span className={muted}>Searched </span>
|
||||||
|
{name(domains[0])}
|
||||||
|
<span className={muted}>, </span>
|
||||||
|
{name(domains[1])}
|
||||||
|
<span className={muted}>{` and ${others} other${others !== 1 ? "s" : ""}`}</span>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type RollPhase = "searching" | "rolling" | "settled";
|
||||||
|
|
||||||
export function WebSearchResult({ query, results, status, title = "Searched the web" }: WebSearchResultProps) {
|
export function WebSearchResult({ query, results, status, title = "Searched the web" }: WebSearchResultProps) {
|
||||||
const isRunning = status === "pending" || status === "running";
|
const isRunning = status === "pending" || status === "running";
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
const domains = useMemo(() => uniqueDomains(results), [results]);
|
||||||
<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">
|
// Drive the one-shot rolling reveal. Results arrive all at once, so we
|
||||||
<div className="flex items-center gap-2">
|
// simulate "fetching one site at a time" by stepping through them with the
|
||||||
<GlobeIcon className="size-4 text-muted-foreground" />
|
// same slide animation the tool group uses, then settle on a summary.
|
||||||
<span className="font-medium text-sm">{title}</span>
|
// `settled` is seeded from the initial status so a card loaded already-
|
||||||
</div>
|
// complete from history skips straight to the summary (no roll).
|
||||||
<ChevronDownIcon className="size-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
|
const [settled, setSettled] = useState(() => !isRunning);
|
||||||
</CollapsibleTrigger>
|
const [rollIndex, setRollIndex] = useState(0);
|
||||||
<CollapsibleContent>
|
|
||||||
<div className="px-3 pb-3 space-y-3">
|
// Phase is fully derived: searching while the tool runs, rolling once
|
||||||
{/* Query + result count */}
|
// results land, then settled. No setState-in-effect needed for transitions.
|
||||||
<div className="flex items-center justify-between gap-2">
|
const phase: RollPhase = isRunning
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground min-w-0">
|
? "searching"
|
||||||
<GlobeIcon className="size-3.5 shrink-0" />
|
: !settled && results.length > 0
|
||||||
<span className="truncate">{query}</span>
|
? "rolling"
|
||||||
</div>
|
: "settled";
|
||||||
{results.length > 0 && (
|
|
||||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
// Warm the browser cache for every favicon the moment results arrive, so
|
||||||
{results.length} result{results.length !== 1 ? "s" : ""}
|
// 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>
|
||||||
)}
|
)}
|
||||||
|
</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")} />
|
||||||
|
</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]">
|
||||||
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Results list */}
|
{/* Results list */}
|
||||||
|
|
@ -73,7 +255,7 @@ export function WebSearchResult({ query, results, status, title = "Searched the
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<img
|
<img
|
||||||
src={`https://www.google.com/s2/favicons?domain=${domain}&sz=16`}
|
src={faviconUrl(domain)}
|
||||||
alt=""
|
alt=""
|
||||||
className="size-4 shrink-0"
|
className="size-4 shrink-0"
|
||||||
/>
|
/>
|
||||||
|
|
@ -88,20 +270,13 @@ export function WebSearchResult({ query, results, status, title = "Searched the
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Status */}
|
{/* Status — only while the search is still running. */}
|
||||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
{isRunning && (
|
||||||
{isRunning ? (
|
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
<>
|
<LoaderIcon className="size-3.5 animate-spin" />
|
||||||
<LoaderIcon className="size-3.5 animate-spin" />
|
<span>Searching...</span>
|
||||||
<span>Searching...</span>
|
</div>
|
||||||
</>
|
)}
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<CheckCircleIcon className="size-3.5 text-green-600" />
|
|
||||||
<span>Done</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</CollapsibleContent>
|
</CollapsibleContent>
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
|
|
|
||||||
|
|
@ -1237,6 +1237,8 @@ function TaskDetail({
|
||||||
const [confirmingDelete, setConfirmingDelete] = useState(false)
|
const [confirmingDelete, setConfirmingDelete] = useState(false)
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(true)
|
const [sidebarOpen, setSidebarOpen] = useState(true)
|
||||||
const [outputRefreshKey, setOutputRefreshKey] = useState(0)
|
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 agentStatus = useBackgroundTaskAgentStatus()
|
||||||
const liveStatus = agentStatus.get(slug)
|
const liveStatus = agentStatus.get(slug)
|
||||||
|
|
@ -1252,6 +1254,23 @@ function TaskDetail({
|
||||||
if (result.success && result.task) {
|
if (result.success && result.task) {
|
||||||
setTask(result.task)
|
setTask(result.task)
|
||||||
setDraft(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 {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||||
import {
|
import {
|
||||||
ArrowUp,
|
ArrowUp,
|
||||||
|
|
@ -10,24 +10,34 @@ import {
|
||||||
FileSpreadsheet,
|
FileSpreadsheet,
|
||||||
FileText,
|
FileText,
|
||||||
FileVideo,
|
FileVideo,
|
||||||
|
FolderCheck,
|
||||||
|
FolderClock,
|
||||||
FolderCog,
|
FolderCog,
|
||||||
|
FolderOpen,
|
||||||
Globe,
|
Globe,
|
||||||
Headphones,
|
Headphones,
|
||||||
ImagePlus,
|
ImagePlus,
|
||||||
LoaderIcon,
|
LoaderIcon,
|
||||||
Mic,
|
Mic,
|
||||||
|
MoreHorizontal,
|
||||||
Plus,
|
Plus,
|
||||||
|
ShieldCheck,
|
||||||
Square,
|
Square,
|
||||||
|
Terminal,
|
||||||
X,
|
X,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuRadioGroup,
|
DropdownMenuRadioGroup,
|
||||||
DropdownMenuRadioItem,
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu'
|
} from '@/components/ui/dropdown-menu'
|
||||||
import {
|
import {
|
||||||
|
|
@ -59,6 +69,12 @@ export type StagedAttachment = {
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024 // 10MB
|
const MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024 // 10MB
|
||||||
|
const MAX_VISIBLE_RECENT_WORK_DIRS = 3
|
||||||
|
const MAX_STORED_RECENT_WORK_DIRS = 8
|
||||||
|
// Stored in the workspace (~/.rowboat/config) so it travels with the workspace and
|
||||||
|
// stays consistent with the other config/*.json files (e.g. coding-agents.json).
|
||||||
|
const RECENT_WORK_DIRS_CONFIG_PATH = 'config/recent-work-dirs.json'
|
||||||
|
const RECENT_WORK_DIRS_CHANGED_EVENT = 'rowboat-chat-recent-work-dirs-changed'
|
||||||
|
|
||||||
|
|
||||||
const providerDisplayNames: Record<string, string> = {
|
const providerDisplayNames: Record<string, string> = {
|
||||||
|
|
@ -79,11 +95,18 @@ interface ConfiguredModel {
|
||||||
model: string
|
model: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type RecentWorkDir = {
|
||||||
|
path: string
|
||||||
|
lastUsedAt: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface SelectedModel {
|
export interface SelectedModel {
|
||||||
provider: string
|
provider: string
|
||||||
model: string
|
model: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type PermissionMode = 'manual' | 'auto'
|
||||||
|
|
||||||
function getSelectedModelDisplayName(model: string) {
|
function getSelectedModelDisplayName(model: string) {
|
||||||
return model.split('/').pop() || model
|
return model.split('/').pop() || model
|
||||||
}
|
}
|
||||||
|
|
@ -107,8 +130,86 @@ function getAttachmentIcon(kind: AttachmentIconKind) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeRecentWorkDir(value: unknown): RecentWorkDir | null {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const path = value.trim()
|
||||||
|
return path ? { path, lastUsedAt: 0 } : null
|
||||||
|
}
|
||||||
|
if (!value || typeof value !== 'object') return null
|
||||||
|
const entry = value as Record<string, unknown>
|
||||||
|
const path = typeof entry.path === 'string' ? entry.path.trim() : ''
|
||||||
|
const lastUsedAt = typeof entry.lastUsedAt === 'number' && Number.isFinite(entry.lastUsedAt)
|
||||||
|
? entry.lastUsedAt
|
||||||
|
: 0
|
||||||
|
return path ? { path, lastUsedAt } : null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readRecentWorkDirs(): Promise<RecentWorkDir[]> {
|
||||||
|
try {
|
||||||
|
const result = await window.ipc.invoke('workspace:readFile', { path: RECENT_WORK_DIRS_CONFIG_PATH })
|
||||||
|
const parsed = JSON.parse(result.data)
|
||||||
|
if (!Array.isArray(parsed)) return []
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const dirs: RecentWorkDir[] = []
|
||||||
|
for (const value of parsed) {
|
||||||
|
const entry = normalizeRecentWorkDir(value)
|
||||||
|
if (!entry || seen.has(entry.path)) continue
|
||||||
|
seen.add(entry.path)
|
||||||
|
dirs.push(entry)
|
||||||
|
if (dirs.length >= MAX_STORED_RECENT_WORK_DIRS) break
|
||||||
|
}
|
||||||
|
return dirs
|
||||||
|
} catch {
|
||||||
|
// File missing or invalid — no recents yet.
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeRecentWorkDirs(dirs: RecentWorkDir[]) {
|
||||||
|
try {
|
||||||
|
await window.ipc.invoke('workspace:writeFile', {
|
||||||
|
path: RECENT_WORK_DIRS_CONFIG_PATH,
|
||||||
|
data: JSON.stringify(dirs.slice(0, MAX_STORED_RECENT_WORK_DIRS), null, 2),
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to persist recent work directories', err)
|
||||||
|
}
|
||||||
|
// Notify other mounted chat inputs in this window to re-read.
|
||||||
|
window.dispatchEvent(new CustomEvent(RECENT_WORK_DIRS_CHANGED_EVENT))
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRecentWorkDirTime(lastUsedAt: number) {
|
||||||
|
if (!lastUsedAt) return ''
|
||||||
|
const now = Date.now()
|
||||||
|
const diffMs = Math.max(0, now - lastUsedAt)
|
||||||
|
const minute = 60 * 1000
|
||||||
|
const hour = 60 * minute
|
||||||
|
const day = 24 * hour
|
||||||
|
if (diffMs < minute) return 'now'
|
||||||
|
if (diffMs < hour) return `${Math.max(1, Math.floor(diffMs / minute))}m ago`
|
||||||
|
if (diffMs < day) return `${Math.floor(diffMs / hour)}h ago`
|
||||||
|
|
||||||
|
const used = new Date(lastUsedAt)
|
||||||
|
const yesterday = new Date(now - day)
|
||||||
|
if (
|
||||||
|
used.getFullYear() === yesterday.getFullYear() &&
|
||||||
|
used.getMonth() === yesterday.getMonth() &&
|
||||||
|
used.getDate() === yesterday.getDate()
|
||||||
|
) {
|
||||||
|
return 'Yesterday'
|
||||||
|
}
|
||||||
|
if (diffMs < 7 * day) {
|
||||||
|
return used.toLocaleDateString(undefined, { weekday: 'short' })
|
||||||
|
}
|
||||||
|
return used.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function compactWorkDirPath(path: string) {
|
||||||
|
return path.replace(/^\/Users\/[^/]+/, '~')
|
||||||
|
}
|
||||||
|
|
||||||
interface ChatInputInnerProps {
|
interface ChatInputInnerProps {
|
||||||
onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[], searchEnabled?: boolean) => void
|
onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[], searchEnabled?: boolean, codeMode?: 'claude' | 'codex', permissionMode?: PermissionMode) => void
|
||||||
onStop?: () => void
|
onStop?: () => void
|
||||||
isProcessing: boolean
|
isProcessing: boolean
|
||||||
isStopping?: boolean
|
isStopping?: boolean
|
||||||
|
|
@ -178,11 +279,62 @@ function ChatInputInner({
|
||||||
const [searchEnabled, setSearchEnabled] = useState(false)
|
const [searchEnabled, setSearchEnabled] = useState(false)
|
||||||
const [searchAvailable, setSearchAvailable] = useState(false)
|
const [searchAvailable, setSearchAvailable] = useState(false)
|
||||||
const [isRowboatConnected, setIsRowboatConnected] = useState(false)
|
const [isRowboatConnected, setIsRowboatConnected] = useState(false)
|
||||||
|
const [codingAgent, setCodingAgent] = useState<'claude' | 'codex'>('claude')
|
||||||
|
const [codeModeEnabled, setCodeModeEnabled] = useState(false)
|
||||||
|
const [codeModeFeatureEnabled, setCodeModeFeatureEnabled] = useState(false)
|
||||||
|
const [permissionMode, setPermissionMode] = useState<PermissionMode>('auto')
|
||||||
|
const [recentWorkDirs, setRecentWorkDirs] = useState<RecentWorkDir[]>([])
|
||||||
|
|
||||||
|
// 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))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// When a run exists, freeze the dropdown to the run's resolved model+provider.
|
// When a run exists, freeze the dropdown to the run's resolved model+provider.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!runId) {
|
if (!runId) {
|
||||||
setLockedModel(null)
|
setLockedModel(null)
|
||||||
|
setPermissionMode('auto')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
|
|
@ -191,10 +343,20 @@ function ChatInputInner({
|
||||||
if (run.provider && run.model) {
|
if (run.provider && run.model) {
|
||||||
setLockedModel({ provider: run.provider, model: run.model })
|
setLockedModel({ provider: run.provider, model: run.model })
|
||||||
}
|
}
|
||||||
|
setPermissionMode(run.permissionMode ?? 'manual')
|
||||||
}).catch(() => { /* legacy run or fetch failure — leave unlocked */ })
|
}).catch(() => { /* legacy run or fetch failure — leave unlocked */ })
|
||||||
return () => { cancelled = true }
|
return () => { cancelled = true }
|
||||||
}, [runId])
|
}, [runId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const syncRecentWorkDirs = () => { void readRecentWorkDirs().then(setRecentWorkDirs) }
|
||||||
|
syncRecentWorkDirs()
|
||||||
|
window.addEventListener(RECENT_WORK_DIRS_CHANGED_EVENT, syncRecentWorkDirs)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener(RECENT_WORK_DIRS_CHANGED_EVENT, syncRecentWorkDirs)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
// Check Rowboat sign-in state
|
// Check Rowboat sign-in state
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
window.ipc.invoke('oauth:getState', null).then((result) => {
|
window.ipc.invoke('oauth:getState', null).then((result) => {
|
||||||
|
|
@ -260,8 +422,90 @@ function ChatInputInner({
|
||||||
return () => window.removeEventListener('models-config-changed', handler)
|
return () => window.removeEventListener('models-config-changed', handler)
|
||||||
}, [loadModelConfig])
|
}, [loadModelConfig])
|
||||||
|
|
||||||
|
// Load the global code-mode feature flag (from settings) and stay in sync.
|
||||||
|
useEffect(() => {
|
||||||
|
const load = () => {
|
||||||
|
window.ipc.invoke('codeMode:getConfig', null)
|
||||||
|
.then((r) => setCodeModeFeatureEnabled(r.enabled))
|
||||||
|
.catch(() => setCodeModeFeatureEnabled(false))
|
||||||
|
}
|
||||||
|
load()
|
||||||
|
window.addEventListener('code-mode-config-changed', load)
|
||||||
|
return () => window.removeEventListener('code-mode-config-changed', load)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// If the feature is turned off in settings, also turn off any per-conversation chip.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!codeModeFeatureEnabled && codeModeEnabled) {
|
||||||
|
setCodeModeEnabled(false)
|
||||||
|
}
|
||||||
|
}, [codeModeFeatureEnabled, codeModeEnabled])
|
||||||
|
|
||||||
|
|
||||||
|
// Cross-platform basename — handles both / and \ separators.
|
||||||
|
const basename = useCallback((p: string): string => {
|
||||||
|
const trimmed = p.replace(/[\\/]+$/, '')
|
||||||
|
const idx = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\'))
|
||||||
|
return idx >= 0 ? trimmed.slice(idx + 1) : trimmed
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const rememberWorkDir = useCallback(async (dir: string) => {
|
||||||
|
const trimmed = dir.trim()
|
||||||
|
if (!trimmed) return
|
||||||
|
const next = [
|
||||||
|
{ path: trimmed, lastUsedAt: Date.now() },
|
||||||
|
...(await readRecentWorkDirs()).filter((item) => item.path !== trimmed),
|
||||||
|
].slice(0, MAX_STORED_RECENT_WORK_DIRS)
|
||||||
|
setRecentWorkDirs(next)
|
||||||
|
await writeRecentWorkDirs(next)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Load coding-agent preference for a given workdir.
|
||||||
|
// Storage: config/coding-agents.json — { [workDirPath]: 'claude' | 'codex' }
|
||||||
|
const loadCodingAgentFor = useCallback(async (dir: string | null): Promise<'claude' | 'codex'> => {
|
||||||
|
if (!dir) return 'claude'
|
||||||
|
try {
|
||||||
|
const result = await window.ipc.invoke('workspace:readFile', { path: 'config/coding-agents.json' })
|
||||||
|
const parsed = JSON.parse(result.data) as Record<string, unknown>
|
||||||
|
const value = parsed?.[dir]
|
||||||
|
if (value === 'codex' || value === 'claude') return value
|
||||||
|
} catch {
|
||||||
|
/* file missing or invalid — fall through to default */
|
||||||
|
}
|
||||||
|
return 'claude'
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const persistCodingAgent = useCallback(async (dir: string, agent: 'claude' | 'codex') => {
|
||||||
|
const existing: Record<string, 'claude' | 'codex'> = {}
|
||||||
|
try {
|
||||||
|
const result = await window.ipc.invoke('workspace:readFile', { path: 'config/coding-agents.json' })
|
||||||
|
const parsed = JSON.parse(result.data) as Record<string, unknown>
|
||||||
|
for (const [k, v] of Object.entries(parsed ?? {})) {
|
||||||
|
if (v === 'claude' || v === 'codex') existing[k] = v
|
||||||
|
}
|
||||||
|
} catch { /* start fresh */ }
|
||||||
|
existing[dir] = agent
|
||||||
|
await window.ipc.invoke('workspace:writeFile', {
|
||||||
|
path: 'config/coding-agents.json',
|
||||||
|
data: JSON.stringify(existing, null, 2),
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
// Work directory is owned per-chat by the parent (App). This component only
|
// Work directory is owned per-chat by the parent (App). This component only
|
||||||
// drives the picker dialog and reports changes up via onWorkDirChange.
|
// drives the picker dialog and reports changes up via onWorkDirChange. Whenever
|
||||||
|
// the work directory changes, load its persisted coding-agent preference.
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
loadCodingAgentFor(workDir).then((agent) => {
|
||||||
|
if (!cancelled) setCodingAgent(agent)
|
||||||
|
})
|
||||||
|
return () => { cancelled = true }
|
||||||
|
}, [workDir, loadCodingAgentFor])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isActive && workDir) void rememberWorkDir(workDir)
|
||||||
|
}, [isActive, workDir, rememberWorkDir])
|
||||||
|
|
||||||
const handleSetWorkDir = useCallback(async () => {
|
const handleSetWorkDir = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
let defaultPath: string | undefined = workDir ?? undefined
|
let defaultPath: string | undefined = workDir ?? undefined
|
||||||
|
|
@ -282,18 +526,43 @@ function ChatInputInner({
|
||||||
})
|
})
|
||||||
if (!chosen) return
|
if (!chosen) return
|
||||||
onWorkDirChange?.(chosen)
|
onWorkDirChange?.(chosen)
|
||||||
|
await rememberWorkDir(chosen)
|
||||||
|
setCodingAgent(await loadCodingAgentFor(chosen))
|
||||||
toast.success(`Work directory set: ${chosen}`)
|
toast.success(`Work directory set: ${chosen}`)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to set work directory', err)
|
console.error('Failed to set work directory', err)
|
||||||
toast.error('Failed to set work directory')
|
toast.error('Failed to set work directory')
|
||||||
}
|
}
|
||||||
}, [workDir, onWorkDirChange])
|
}, [workDir, onWorkDirChange, rememberWorkDir, loadCodingAgentFor])
|
||||||
|
|
||||||
|
const handleSelectRecentWorkDir = useCallback(async (dir: string) => {
|
||||||
|
onWorkDirChange?.(dir)
|
||||||
|
await rememberWorkDir(dir)
|
||||||
|
setCodingAgent(await loadCodingAgentFor(dir))
|
||||||
|
toast.success(`Work directory set: ${dir}`)
|
||||||
|
}, [onWorkDirChange, rememberWorkDir, loadCodingAgentFor])
|
||||||
|
|
||||||
const handleClearWorkDir = useCallback(() => {
|
const handleClearWorkDir = useCallback(() => {
|
||||||
onWorkDirChange?.(null)
|
onWorkDirChange?.(null)
|
||||||
|
setCodingAgent('claude')
|
||||||
toast.success('Work directory cleared')
|
toast.success('Work directory cleared')
|
||||||
}, [onWorkDirChange])
|
}, [onWorkDirChange])
|
||||||
|
|
||||||
|
const handleToggleCodingAgent = useCallback(async () => {
|
||||||
|
const next: 'claude' | 'codex' = codingAgent === 'claude' ? 'codex' : 'claude'
|
||||||
|
setCodingAgent(next)
|
||||||
|
// Persist only when scoped to a workdir; without one there's nothing to key on.
|
||||||
|
if (!workDir) return
|
||||||
|
try {
|
||||||
|
await persistCodingAgent(workDir, next)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to save coding agent', err)
|
||||||
|
toast.error('Failed to save coding agent')
|
||||||
|
// revert on failure
|
||||||
|
setCodingAgent(codingAgent)
|
||||||
|
}
|
||||||
|
}, [workDir, codingAgent, persistCodingAgent])
|
||||||
|
|
||||||
// Check search tool availability (exa or signed-in via gateway)
|
// Check search tool availability (exa or signed-in via gateway)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const checkSearch = async () => {
|
const checkSearch = async () => {
|
||||||
|
|
@ -378,12 +647,15 @@ function ChatInputInner({
|
||||||
|
|
||||||
const handleSubmit = useCallback(() => {
|
const handleSubmit = useCallback(() => {
|
||||||
if (!canSubmit) return
|
if (!canSubmit) return
|
||||||
onSubmit({ text: message.trim(), files: [] }, controller.mentions.mentions, attachments, searchEnabled || undefined)
|
// 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)
|
||||||
controller.textInput.clear()
|
controller.textInput.clear()
|
||||||
controller.mentions.clearMentions()
|
controller.mentions.clearMentions()
|
||||||
setAttachments([])
|
setAttachments([])
|
||||||
setSearchEnabled(false)
|
// Web search toggle stays on for the rest of the chat session; the user
|
||||||
}, [attachments, canSubmit, controller, message, onSubmit, searchEnabled])
|
// turns it off explicitly. (Not persisted across app restarts.)
|
||||||
|
}, [attachments, canSubmit, controller, message, onSubmit, searchEnabled, codeModeEnabled, codingAgent, permissionMode, workDir])
|
||||||
|
|
||||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
|
@ -422,6 +694,12 @@ function ChatInputInner({
|
||||||
}
|
}
|
||||||
}, [addFiles, isActive])
|
}, [addFiles, isActive])
|
||||||
|
|
||||||
|
const visibleRecentWorkDirs = recentWorkDirs
|
||||||
|
.filter((entry) => entry.path !== workDir)
|
||||||
|
.slice(0, MAX_VISIBLE_RECENT_WORK_DIRS)
|
||||||
|
const currentWorkDirLabel = workDir ? basename(workDir) || workDir : 'Not set'
|
||||||
|
const currentWorkDirPath = workDir ? compactWorkDirPath(workDir) : ''
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rowboat-chat-input rounded-lg border border-border bg-background shadow-none">
|
<div className="rowboat-chat-input rounded-lg border border-border bg-background shadow-none">
|
||||||
{attachments.length > 0 && (
|
{attachments.length > 0 && (
|
||||||
|
|
@ -526,48 +804,138 @@ function ChatInputInner({
|
||||||
className="min-h-6 rounded-none border-0 py-0 shadow-none focus-visible:ring-0"
|
className="min-h-6 rounded-none border-0 py-0 shadow-none focus-visible:ring-0"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 px-4 pb-3">
|
<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">
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<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>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
{workDir && (
|
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<div className="group flex h-7 max-w-[180px] shrink-0 items-center rounded-full border border-border bg-muted/40 pl-2.5 pr-2 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground">
|
<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>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
{workDir && collapseLevel < 8 && (
|
||||||
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleSetWorkDir}
|
onClick={handleSetWorkDir}
|
||||||
className="flex min-w-0 items-center gap-1.5"
|
className="flex min-w-0 items-center gap-1.5"
|
||||||
>
|
>
|
||||||
<FolderCog className="h-3.5 w-3.5 shrink-0" />
|
<FolderCog className="h-3.5 w-3.5 shrink-0" />
|
||||||
<span className="truncate">{workDir.split('/').pop() || workDir}</span>
|
{collapseLevel < 4 && <span className="truncate">{basename(workDir) || workDir}</span>}
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleClearWorkDir}
|
|
||||||
aria-label="Remove work directory"
|
|
||||||
className="flex h-3.5 w-0 shrink-0 items-center justify-center overflow-hidden opacity-0 transition-all duration-150 ease-out hover:text-red-500 group-hover:ml-1 group-hover:w-3.5 group-hover:opacity-100"
|
|
||||||
>
|
|
||||||
<X className="h-3.5 w-3.5 shrink-0" />
|
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="top">
|
<TooltipContent side="top">
|
||||||
|
|
@ -575,7 +943,7 @@ function ChatInputInner({
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
{searchAvailable && (
|
{searchAvailable && collapseLevel < 7 && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setSearchEnabled((v) => !v)}
|
onClick={() => setSearchEnabled((v) => !v)}
|
||||||
|
|
@ -589,35 +957,191 @@ function ChatInputInner({
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Globe className="h-4 w-4 shrink-0" />
|
<Globe className="h-4 w-4 shrink-0" />
|
||||||
<span
|
{searchEnabled && collapseLevel < 3 && (
|
||||||
className={cn(
|
<span className="ml-1.5 whitespace-nowrap text-xs font-medium">
|
||||||
'overflow-hidden whitespace-nowrap text-xs font-medium transition-all duration-150 ease-out',
|
Search
|
||||||
searchEnabled ? 'ml-1.5 max-w-[60px] opacity-100' : 'max-w-0 opacity-0'
|
</span>
|
||||||
)}
|
)}
|
||||||
>
|
|
||||||
Search
|
|
||||||
</span>
|
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{collapseLevel < 6 && (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (runId) return
|
||||||
|
setPermissionMode((mode) => mode === 'auto' ? 'manual' : 'auto')
|
||||||
|
}}
|
||||||
|
disabled={Boolean(runId)}
|
||||||
|
className={cn(
|
||||||
|
"flex h-7 shrink-0 items-center gap-1.5 rounded-full 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"
|
||||||
|
>
|
||||||
|
<ShieldCheck className="h-3.5 w-3.5 shrink-0" />
|
||||||
|
{collapseLevel < 2 && <span>{permissionMode === 'auto' ? 'Auto' : 'Manual'}</span>}
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top">
|
||||||
|
{runId
|
||||||
|
? `Permission mode is fixed for this run: ${permissionMode === 'auto' ? 'Auto' : 'Manual'}`
|
||||||
|
: permissionMode === 'auto'
|
||||||
|
? 'Auto-permission on — click for manual approval prompts'
|
||||||
|
: 'Manual approval prompts — click for auto-permission'}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{codeModeFeatureEnabled && 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>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<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" />
|
<div className="flex-1" />
|
||||||
{lockedModel ? (
|
{lockedModel ? (
|
||||||
<span
|
<span
|
||||||
className="flex h-7 shrink-0 items-center gap-1 rounded-full px-2 text-xs text-muted-foreground"
|
className="flex h-7 min-w-0 items-center gap-1 rounded-full px-2 text-xs text-muted-foreground"
|
||||||
title={`${providerDisplayNames[lockedModel.provider] || lockedModel.provider} — fixed for this chat`}
|
title={`${providerDisplayNames[lockedModel.provider] || lockedModel.provider} — fixed for this chat`}
|
||||||
>
|
>
|
||||||
<span className="max-w-[150px] truncate">{getSelectedModelDisplayName(lockedModel.model)}</span>
|
<span className="min-w-0 truncate">{getSelectedModelDisplayName(lockedModel.model)}</span>
|
||||||
</span>
|
</span>
|
||||||
) : configuredModels.length > 0 ? (
|
) : configuredModels.length > 0 ? (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex h-7 shrink-0 items-center gap-1 rounded-full px-2 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
className="flex h-7 min-w-0 items-center gap-1 rounded-full px-2 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||||
>
|
>
|
||||||
<span className="max-w-[150px] truncate">
|
<span className="min-w-0 truncate">
|
||||||
{getSelectedModelDisplayName(configuredModels.find((m) => `${m.provider}/${m.model}` === activeModelKey)?.model || configuredModels[0]?.model || 'Model')}
|
{getSelectedModelDisplayName(configuredModels.find((m) => `${m.provider}/${m.model}` === activeModelKey)?.model || configuredModels[0]?.model || 'Model')}
|
||||||
</span>
|
</span>
|
||||||
<ChevronDown className="h-3 w-3" />
|
<ChevronDown className="h-3 w-3 shrink-0" />
|
||||||
</button>
|
</button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
|
|
@ -759,7 +1283,7 @@ export interface ChatInputWithMentionsProps {
|
||||||
knowledgeFiles: string[]
|
knowledgeFiles: string[]
|
||||||
recentFiles: string[]
|
recentFiles: string[]
|
||||||
visibleFiles: string[]
|
visibleFiles: string[]
|
||||||
onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[]) => void
|
onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[], searchEnabled?: boolean, codeMode?: 'claude' | 'codex', permissionMode?: PermissionMode) => void
|
||||||
onStop?: () => void
|
onStop?: () => void
|
||||||
isProcessing: boolean
|
isProcessing: boolean
|
||||||
isStopping?: boolean
|
isStopping?: boolean
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,18 @@
|
||||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { ArrowRight, X } from 'lucide-react'
|
import { ArrowLeft, ArrowRight, Bug, MoreHorizontal } from 'lucide-react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||||
import { ChatHeader } from '@/components/chat-header'
|
import { ChatHeader } from '@/components/chat-header'
|
||||||
import { ChatEmptyState } from '@/components/chat-empty-state'
|
import { ChatEmptyState } from '@/components/chat-empty-state'
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu'
|
||||||
import {
|
import {
|
||||||
Conversation,
|
Conversation,
|
||||||
ConversationContent,
|
ConversationContent,
|
||||||
|
|
@ -21,6 +28,7 @@ import { Tool, ToolContent, ToolGroupComponent, ToolHeader, ToolTabbedContent }
|
||||||
import { WebSearchResult } from '@/components/ai-elements/web-search-result'
|
import { WebSearchResult } from '@/components/ai-elements/web-search-result'
|
||||||
import { ComposioConnectCard } from '@/components/ai-elements/composio-connect-card'
|
import { ComposioConnectCard } from '@/components/ai-elements/composio-connect-card'
|
||||||
import { PermissionRequest } from '@/components/ai-elements/permission-request'
|
import { PermissionRequest } from '@/components/ai-elements/permission-request'
|
||||||
|
import { AutoPermissionDecision } from '@/components/ai-elements/auto-permission-decision'
|
||||||
import { TerminalOutput } from '@/components/terminal-output'
|
import { TerminalOutput } from '@/components/terminal-output'
|
||||||
import { AskHumanRequest } from '@/components/ai-elements/ask-human-request'
|
import { AskHumanRequest } from '@/components/ai-elements/ask-human-request'
|
||||||
import { type PromptInputMessage, type FileMention } from '@/components/ai-elements/prompt-input'
|
import { type PromptInputMessage, type FileMention } from '@/components/ai-elements/prompt-input'
|
||||||
|
|
@ -29,10 +37,11 @@ import { MarkdownPreOverride } from '@/components/ai-elements/markdown-code-over
|
||||||
import { defaultRemarkPlugins } from 'streamdown'
|
import { defaultRemarkPlugins } from 'streamdown'
|
||||||
import remarkBreaks from 'remark-breaks'
|
import remarkBreaks from 'remark-breaks'
|
||||||
import { type ChatTab } from '@/components/tab-bar'
|
import { type ChatTab } from '@/components/tab-bar'
|
||||||
import { ChatInputWithMentions, type StagedAttachment, type SelectedModel } from '@/components/chat-input-with-mentions'
|
import { ChatInputWithMentions, type PermissionMode, type StagedAttachment, type SelectedModel } from '@/components/chat-input-with-mentions'
|
||||||
import { ChatMessageAttachments } from '@/components/chat-message-attachments'
|
import { ChatMessageAttachments } from '@/components/chat-message-attachments'
|
||||||
import { useSidebar } from '@/components/ui/sidebar'
|
import { useSidebar } from '@/components/ui/sidebar'
|
||||||
import { wikiLabel } from '@/lib/wiki-links'
|
import { wikiLabel } from '@/lib/wiki-links'
|
||||||
|
import type { ChatPaneSize } from '@/contexts/theme-context'
|
||||||
import {
|
import {
|
||||||
type ChatViewportAnchorState,
|
type ChatViewportAnchorState,
|
||||||
type ChatTabViewState,
|
type ChatTabViewState,
|
||||||
|
|
@ -117,6 +126,9 @@ interface ChatSidebarProps {
|
||||||
defaultWidth?: number
|
defaultWidth?: number
|
||||||
isOpen?: boolean
|
isOpen?: boolean
|
||||||
isMaximized?: boolean
|
isMaximized?: boolean
|
||||||
|
placement?: 'middle' | 'right'
|
||||||
|
paneSize?: ChatPaneSize
|
||||||
|
className?: string
|
||||||
chatTabs: ChatTab[]
|
chatTabs: ChatTab[]
|
||||||
activeChatTabId: string
|
activeChatTabId: string
|
||||||
getChatTabTitle: (tab: ChatTab) => string
|
getChatTabTitle: (tab: ChatTab) => string
|
||||||
|
|
@ -125,7 +137,6 @@ interface ChatSidebarProps {
|
||||||
onSelectRun?: (runId: string) => void
|
onSelectRun?: (runId: string) => void
|
||||||
onOpenChatHistory?: () => void
|
onOpenChatHistory?: () => void
|
||||||
onOpenFullScreen?: () => void
|
onOpenFullScreen?: () => void
|
||||||
onCloseChat?: () => void
|
|
||||||
conversation: ConversationItem[]
|
conversation: ConversationItem[]
|
||||||
currentAssistantMessage: string
|
currentAssistantMessage: string
|
||||||
chatTabStates?: Record<string, ChatTabViewState>
|
chatTabStates?: Record<string, ChatTabViewState>
|
||||||
|
|
@ -133,7 +144,7 @@ interface ChatSidebarProps {
|
||||||
isProcessing: boolean
|
isProcessing: boolean
|
||||||
isStopping?: boolean
|
isStopping?: boolean
|
||||||
onStop?: () => void
|
onStop?: () => void
|
||||||
onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[]) => void
|
onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[], searchEnabled?: boolean, codeMode?: 'claude' | 'codex', permissionMode?: PermissionMode) => void
|
||||||
knowledgeFiles?: string[]
|
knowledgeFiles?: string[]
|
||||||
recentFiles?: string[]
|
recentFiles?: string[]
|
||||||
visibleFiles?: string[]
|
visibleFiles?: string[]
|
||||||
|
|
@ -148,6 +159,7 @@ interface ChatSidebarProps {
|
||||||
pendingAskHumanRequests?: ChatTabViewState['pendingAskHumanRequests']
|
pendingAskHumanRequests?: ChatTabViewState['pendingAskHumanRequests']
|
||||||
allPermissionRequests?: ChatTabViewState['allPermissionRequests']
|
allPermissionRequests?: ChatTabViewState['allPermissionRequests']
|
||||||
permissionResponses?: ChatTabViewState['permissionResponses']
|
permissionResponses?: ChatTabViewState['permissionResponses']
|
||||||
|
autoPermissionDecisions?: ChatTabViewState['autoPermissionDecisions']
|
||||||
onPermissionResponse?: (toolCallId: string, subflow: string[], response: PermissionResponse, scope?: 'once' | 'session' | 'always') => void
|
onPermissionResponse?: (toolCallId: string, subflow: string[], response: PermissionResponse, scope?: 'once' | 'session' | 'always') => void
|
||||||
onAskHumanResponse?: (toolCallId: string, subflow: string[], response: string) => void
|
onAskHumanResponse?: (toolCallId: string, subflow: string[], response: string) => void
|
||||||
isToolOpenForTab?: (tabId: string, toolId: string) => boolean
|
isToolOpenForTab?: (tabId: string, toolId: string) => boolean
|
||||||
|
|
@ -175,6 +187,9 @@ export function ChatSidebar({
|
||||||
defaultWidth = DEFAULT_WIDTH,
|
defaultWidth = DEFAULT_WIDTH,
|
||||||
isOpen = true,
|
isOpen = true,
|
||||||
isMaximized = false,
|
isMaximized = false,
|
||||||
|
placement = 'right',
|
||||||
|
paneSize = 'chat-smaller',
|
||||||
|
className,
|
||||||
chatTabs,
|
chatTabs,
|
||||||
activeChatTabId,
|
activeChatTabId,
|
||||||
getChatTabTitle,
|
getChatTabTitle,
|
||||||
|
|
@ -183,7 +198,6 @@ export function ChatSidebar({
|
||||||
onSelectRun,
|
onSelectRun,
|
||||||
onOpenChatHistory,
|
onOpenChatHistory,
|
||||||
onOpenFullScreen,
|
onOpenFullScreen,
|
||||||
onCloseChat,
|
|
||||||
conversation,
|
conversation,
|
||||||
currentAssistantMessage,
|
currentAssistantMessage,
|
||||||
chatTabStates = {},
|
chatTabStates = {},
|
||||||
|
|
@ -206,6 +220,7 @@ export function ChatSidebar({
|
||||||
pendingAskHumanRequests = new Map(),
|
pendingAskHumanRequests = new Map(),
|
||||||
allPermissionRequests = new Map(),
|
allPermissionRequests = new Map(),
|
||||||
permissionResponses = new Map(),
|
permissionResponses = new Map(),
|
||||||
|
autoPermissionDecisions = new Map(),
|
||||||
onPermissionResponse,
|
onPermissionResponse,
|
||||||
onAskHumanResponse,
|
onAskHumanResponse,
|
||||||
isToolOpenForTab,
|
isToolOpenForTab,
|
||||||
|
|
@ -238,6 +253,8 @@ export function ChatSidebar({
|
||||||
const startWidthRef = useRef(0)
|
const startWidthRef = useRef(0)
|
||||||
const prevIsMaximizedRef = useRef(isMaximized)
|
const prevIsMaximizedRef = useRef(isMaximized)
|
||||||
const justToggledMaximize = prevIsMaximizedRef.current !== isMaximized
|
const justToggledMaximize = prevIsMaximizedRef.current !== isMaximized
|
||||||
|
const isMiddlePlacement = placement === 'middle'
|
||||||
|
const isResizable = paneSize === 'chat-smaller'
|
||||||
|
|
||||||
const getMaxAllowedWidth = useCallback(() => {
|
const getMaxAllowedWidth = useCallback(() => {
|
||||||
if (typeof window === 'undefined') return MAX_WIDTH
|
if (typeof window === 'undefined') return MAX_WIDTH
|
||||||
|
|
@ -298,7 +315,9 @@ export function ChatSidebar({
|
||||||
setIsResizing(true)
|
setIsResizing(true)
|
||||||
|
|
||||||
const handleMouseMove = (event: MouseEvent) => {
|
const handleMouseMove = (event: MouseEvent) => {
|
||||||
const delta = startXRef.current - event.clientX
|
const delta = isMiddlePlacement
|
||||||
|
? event.clientX - startXRef.current
|
||||||
|
: startXRef.current - event.clientX
|
||||||
const maxAllowedWidth = getMaxAllowedWidth()
|
const maxAllowedWidth = getMaxAllowedWidth()
|
||||||
setWidth(clampPaneWidth(startWidthRef.current + delta, maxAllowedWidth))
|
setWidth(clampPaneWidth(startWidthRef.current + delta, maxAllowedWidth))
|
||||||
}
|
}
|
||||||
|
|
@ -311,7 +330,7 @@ export function ChatSidebar({
|
||||||
|
|
||||||
document.addEventListener('mousemove', handleMouseMove)
|
document.addEventListener('mousemove', handleMouseMove)
|
||||||
document.addEventListener('mouseup', handleMouseUp)
|
document.addEventListener('mouseup', handleMouseUp)
|
||||||
}, [width, getMaxAllowedWidth])
|
}, [width, getMaxAllowedWidth, isMiddlePlacement])
|
||||||
|
|
||||||
const activeTabState = useMemo<ChatTabViewState>(() => ({
|
const activeTabState = useMemo<ChatTabViewState>(() => ({
|
||||||
runId: runId ?? null,
|
runId: runId ?? null,
|
||||||
|
|
@ -320,6 +339,7 @@ export function ChatSidebar({
|
||||||
pendingAskHumanRequests,
|
pendingAskHumanRequests,
|
||||||
allPermissionRequests,
|
allPermissionRequests,
|
||||||
permissionResponses,
|
permissionResponses,
|
||||||
|
autoPermissionDecisions,
|
||||||
}), [
|
}), [
|
||||||
runId,
|
runId,
|
||||||
conversation,
|
conversation,
|
||||||
|
|
@ -327,14 +347,38 @@ export function ChatSidebar({
|
||||||
pendingAskHumanRequests,
|
pendingAskHumanRequests,
|
||||||
allPermissionRequests,
|
allPermissionRequests,
|
||||||
permissionResponses,
|
permissionResponses,
|
||||||
|
autoPermissionDecisions,
|
||||||
])
|
])
|
||||||
const emptyTabState = useMemo<ChatTabViewState>(() => createEmptyChatTabViewState(), [])
|
const emptyTabState = useMemo<ChatTabViewState>(() => createEmptyChatTabViewState(), [])
|
||||||
const getTabState = useCallback((tabId: string): ChatTabViewState => {
|
const getTabState = useCallback((tabId: string): ChatTabViewState => {
|
||||||
if (tabId === activeChatTabId) return activeTabState
|
if (tabId === activeChatTabId) return activeTabState
|
||||||
return chatTabStates[tabId] ?? emptyTabState
|
return chatTabStates[tabId] ?? emptyTabState
|
||||||
}, [activeChatTabId, activeTabState, chatTabStates, emptyTabState])
|
}, [activeChatTabId, activeTabState, chatTabStates, emptyTabState])
|
||||||
|
const activeRunId = activeTabState.runId
|
||||||
|
const handleDownloadChatLog = useCallback(async () => {
|
||||||
|
if (!activeRunId) {
|
||||||
|
toast.error('No chat log available yet')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const renderConversationItem = (item: ConversationItem, tabId: string) => {
|
try {
|
||||||
|
const result = await window.ipc.invoke('runs:downloadLog', { runId: activeRunId })
|
||||||
|
if (result.success) {
|
||||||
|
toast.success('Chat log saved')
|
||||||
|
} else if (result.error) {
|
||||||
|
toast.error(result.error)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Download chat log failed:', err)
|
||||||
|
toast.error('Failed to download chat log')
|
||||||
|
}
|
||||||
|
}, [activeRunId])
|
||||||
|
|
||||||
|
const renderConversationItem = (
|
||||||
|
item: ConversationItem,
|
||||||
|
tabId: string,
|
||||||
|
options?: { autoPermissionDetail?: { decision: 'allow'; reason: string } },
|
||||||
|
) => {
|
||||||
if (isChatMessage(item)) {
|
if (isChatMessage(item)) {
|
||||||
if (item.role === 'user') {
|
if (item.role === 'user') {
|
||||||
if (item.attachments && item.attachments.length > 0) {
|
if (item.attachments && item.attachments.length > 0) {
|
||||||
|
|
@ -427,6 +471,7 @@ export function ChatSidebar({
|
||||||
key={item.id}
|
key={item.id}
|
||||||
open={isToolOpenForTab?.(tabId, item.id) ?? false}
|
open={isToolOpenForTab?.(tabId, item.id) ?? false}
|
||||||
onOpenChange={(open) => onToolOpenChangeForTab?.(tabId, item.id, open)}
|
onOpenChange={(open) => onToolOpenChangeForTab?.(tabId, item.id, open)}
|
||||||
|
autoPermissionDetail={options?.autoPermissionDetail}
|
||||||
>
|
>
|
||||||
<ToolHeader title={toolTitle} type={`tool-${item.name}`} state={toToolState(item.status)} />
|
<ToolHeader title={toolTitle} type={`tool-${item.name}`} state={toToolState(item.status)} />
|
||||||
<ToolContent>
|
<ToolContent>
|
||||||
|
|
@ -467,8 +512,11 @@ export function ChatSidebar({
|
||||||
// not add extra width to the right and overflow the app viewport.
|
// not add extra width to the right and overflow the app viewport.
|
||||||
return { width: 0, flex: '1 1 auto' }
|
return { width: 0, flex: '1 1 auto' }
|
||||||
}
|
}
|
||||||
|
if (paneSize === 'chat-equal' || paneSize === 'chat-bigger') {
|
||||||
|
return { width: 0, flex: '1 1 0' }
|
||||||
|
}
|
||||||
return { width, flex: '0 0 auto' }
|
return { width, flex: '0 0 auto' }
|
||||||
}, [isOpen, isMaximized, width])
|
}, [isOpen, isMaximized, paneSize, width])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -477,16 +525,19 @@ export function ChatSidebar({
|
||||||
onMouseDownCapture={onActivate}
|
onMouseDownCapture={onActivate}
|
||||||
onFocusCapture={onActivate}
|
onFocusCapture={onActivate}
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative flex min-w-0 flex-col overflow-hidden border-l border-border bg-background',
|
'relative flex min-w-0 flex-col overflow-hidden bg-background',
|
||||||
!isResizing && !justToggledMaximize && 'transition-[width] duration-200 ease-linear'
|
isMiddlePlacement ? 'border-r border-border' : 'border-l border-border',
|
||||||
|
!isResizing && !justToggledMaximize && 'transition-[width] duration-200 ease-linear',
|
||||||
|
className
|
||||||
)}
|
)}
|
||||||
style={paneStyle}
|
style={paneStyle}
|
||||||
>
|
>
|
||||||
{!isMaximized && (
|
{!isMaximized && isResizable && (
|
||||||
<div
|
<div
|
||||||
onMouseDown={handleMouseDown}
|
onMouseDown={handleMouseDown}
|
||||||
className={cn(
|
className={cn(
|
||||||
'absolute inset-y-0 left-0 z-20 w-4 -translate-x-1/2 cursor-col-resize',
|
'absolute inset-y-0 z-20 w-4 cursor-col-resize',
|
||||||
|
isMiddlePlacement ? 'right-0 translate-x-1/2' : 'left-0 -translate-x-1/2',
|
||||||
'after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] after:transition-colors',
|
'after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] after:transition-colors',
|
||||||
'hover:after:bg-sidebar-border',
|
'hover:after:bg-sidebar-border',
|
||||||
isResizing && 'after:bg-primary'
|
isResizing && 'after:bg-primary'
|
||||||
|
|
@ -515,40 +566,51 @@ export function ChatSidebar({
|
||||||
onSelectRun={onSelectRun}
|
onSelectRun={onSelectRun}
|
||||||
onOpenChatHistory={onOpenChatHistory}
|
onOpenChatHistory={onOpenChatHistory}
|
||||||
/>
|
/>
|
||||||
{isMaximized ? (
|
<DropdownMenu>
|
||||||
onOpenFullScreen && (
|
<Tooltip>
|
||||||
<Tooltip>
|
<TooltipTrigger asChild>
|
||||||
<TooltipTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={onOpenFullScreen}
|
className="titlebar-no-drag my-1 h-8 w-8 shrink-0 text-muted-foreground hover:text-foreground"
|
||||||
className="titlebar-no-drag my-1 mr-2 h-8 w-8 shrink-0 text-muted-foreground hover:text-foreground"
|
aria-label="Chat options"
|
||||||
aria-label="Dock chat to side pane"
|
|
||||||
>
|
>
|
||||||
<ArrowRight className="size-5" />
|
<MoreHorizontal className="size-5" />
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</DropdownMenuTrigger>
|
||||||
<TooltipContent side="bottom">Dock to side pane</TooltipContent>
|
</TooltipTrigger>
|
||||||
</Tooltip>
|
<TooltipContent side="bottom">Chat options</TooltipContent>
|
||||||
)
|
</Tooltip>
|
||||||
) : (
|
<DropdownMenuContent align="end" className="min-w-48">
|
||||||
onCloseChat && (
|
<DropdownMenuItem
|
||||||
<Tooltip>
|
disabled={!activeRunId}
|
||||||
<TooltipTrigger asChild>
|
onSelect={() => {
|
||||||
<Button
|
void handleDownloadChatLog()
|
||||||
variant="ghost"
|
}}
|
||||||
size="icon"
|
>
|
||||||
onClick={onCloseChat}
|
<Bug className="size-4" />
|
||||||
className="titlebar-no-drag my-1 mr-2 h-8 w-8 shrink-0 text-muted-foreground hover:text-foreground"
|
Download chat log
|
||||||
aria-label="Close chat"
|
</DropdownMenuItem>
|
||||||
>
|
</DropdownMenuContent>
|
||||||
<X className="size-5" />
|
</DropdownMenu>
|
||||||
</Button>
|
{onOpenFullScreen && (
|
||||||
</TooltipTrigger>
|
<Tooltip>
|
||||||
<TooltipContent side="bottom">Close chat</TooltipContent>
|
<TooltipTrigger asChild>
|
||||||
</Tooltip>
|
<Button
|
||||||
)
|
variant="ghost"
|
||||||
|
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'}
|
||||||
|
>
|
||||||
|
{isMaximized
|
||||||
|
? (isMiddlePlacement ? <ArrowLeft className="size-5" /> : <ArrowRight className="size-5" />)
|
||||||
|
: (isMiddlePlacement ? <ArrowRight className="size-5" /> : <ArrowLeft className="size-5" />)}
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom">{isMaximized ? 'Dock to side pane' : 'Expand chat'}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|
@ -593,7 +655,7 @@ export function ChatSidebar({
|
||||||
<>
|
<>
|
||||||
{groupConversationItems(
|
{groupConversationItems(
|
||||||
tabState.conversation,
|
tabState.conversation,
|
||||||
(id) => !!tabState.allPermissionRequests.get(id)
|
(id) => !!tabState.allPermissionRequests.get(id) || !!tabState.autoPermissionDecisions.get(id)
|
||||||
).map((item) => {
|
).map((item) => {
|
||||||
if (isToolGroup(item)) {
|
if (isToolGroup(item)) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -605,23 +667,44 @@ export function ChatSidebar({
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
const rendered = renderConversationItem(item, tab.id)
|
const autoDecision = isToolCall(item)
|
||||||
if (isToolCall(item) && onPermissionResponse) {
|
? tabState.autoPermissionDecisions.get(item.id)
|
||||||
|
: undefined
|
||||||
|
const rendered = renderConversationItem(
|
||||||
|
item,
|
||||||
|
tab.id,
|
||||||
|
autoDecision?.decision === 'allow'
|
||||||
|
? { autoPermissionDetail: { decision: 'allow', reason: autoDecision.reason } }
|
||||||
|
: undefined,
|
||||||
|
)
|
||||||
|
if (isToolCall(item)) {
|
||||||
|
const deniedAutoDecision = autoDecision?.decision === 'deny' ? autoDecision : null
|
||||||
const permRequest = tabState.allPermissionRequests.get(item.id)
|
const permRequest = tabState.allPermissionRequests.get(item.id)
|
||||||
if (permRequest) {
|
if (deniedAutoDecision || (permRequest && onPermissionResponse)) {
|
||||||
const response = tabState.permissionResponses.get(item.id) || null
|
const response = tabState.permissionResponses.get(item.id) || null
|
||||||
return (
|
return (
|
||||||
<React.Fragment key={item.id}>
|
<React.Fragment key={item.id}>
|
||||||
|
{deniedAutoDecision && (
|
||||||
|
<AutoPermissionDecision
|
||||||
|
toolCall={deniedAutoDecision.toolCall}
|
||||||
|
permission={deniedAutoDecision.permission}
|
||||||
|
decision={deniedAutoDecision.decision}
|
||||||
|
reason={deniedAutoDecision.reason}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{permRequest && onPermissionResponse && (
|
||||||
|
<PermissionRequest
|
||||||
|
toolCall={permRequest.toolCall}
|
||||||
|
permission={permRequest.permission}
|
||||||
|
onApprove={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve')}
|
||||||
|
onApproveSession={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'session')}
|
||||||
|
onApproveAlways={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'always')}
|
||||||
|
onDeny={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'deny')}
|
||||||
|
isProcessing={isActive && isProcessing}
|
||||||
|
response={response}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{rendered}
|
{rendered}
|
||||||
<PermissionRequest
|
|
||||||
toolCall={permRequest.toolCall}
|
|
||||||
onApprove={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve')}
|
|
||||||
onApproveSession={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'session')}
|
|
||||||
onApproveAlways={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'always')}
|
|
||||||
onDeny={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'deny')}
|
|
||||||
isProcessing={isActive && isProcessing}
|
|
||||||
response={response}
|
|
||||||
/>
|
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
253
apps/x/apps/renderer/src/components/coding-run.tsx
Normal file
253
apps/x/apps/renderer/src/components/coding-run.tsx
Normal file
|
|
@ -0,0 +1,253 @@
|
||||||
|
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} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
196
apps/x/apps/renderer/src/components/docx-file-viewer.tsx
Normal file
196
apps/x/apps/renderer/src/components/docx-file-viewer.tsx
Normal file
|
|
@ -0,0 +1,196 @@
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -69,6 +69,31 @@ function snippet(text?: string): string {
|
||||||
return (text || '').replace(/\s+/g, ' ').trim().slice(0, 180)
|
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 {
|
function getInitial(from?: string): string {
|
||||||
return (extractName(from)[0] || '?').toUpperCase()
|
return (extractName(from)[0] || '?').toUpperCase()
|
||||||
}
|
}
|
||||||
|
|
@ -692,7 +717,7 @@ function ComposeBox({
|
||||||
const initialContent = useMemo(() => {
|
const initialContent = useMemo(() => {
|
||||||
if (mode === 'forward') return buildForwardedContent(thread)
|
if (mode === 'forward') return buildForwardedContent(thread)
|
||||||
// Gmail-side draft (user's own work) wins over the AI-generated draft.
|
// Gmail-side draft (user's own work) wins over the AI-generated draft.
|
||||||
const source = thread.gmail_draft || thread.draft_response
|
const source = stripQuotedReplyText(thread.gmail_draft || thread.draft_response || '')
|
||||||
if (!source) return ''
|
if (!source) return ''
|
||||||
return source
|
return source
|
||||||
.split(/\n{2,}/)
|
.split(/\n{2,}/)
|
||||||
|
|
@ -1048,8 +1073,7 @@ function ThreadDetail({
|
||||||
|
|
||||||
const MAX_KEPT_OPEN = 5
|
const MAX_KEPT_OPEN = 5
|
||||||
const PAGE_SIZE = 25
|
const PAGE_SIZE = 25
|
||||||
const SECTIONS = ['important', 'other'] as const
|
type InboxSection = 'important' | 'other'
|
||||||
type InboxSection = (typeof SECTIONS)[number]
|
|
||||||
|
|
||||||
interface SectionState {
|
interface SectionState {
|
||||||
threads: GmailThread[]
|
threads: GmailThread[]
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
|
ArrowLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Copy,
|
Copy,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
File as FileIcon,
|
|
||||||
FilePlus,
|
FilePlus,
|
||||||
Folder as FolderIcon,
|
FileText,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
FolderPlus,
|
FolderPlus,
|
||||||
Network,
|
Network,
|
||||||
|
|
@ -49,6 +49,10 @@ export type KnowledgeViewActions = {
|
||||||
type KnowledgeViewProps = {
|
type KnowledgeViewProps = {
|
||||||
tree: TreeNode[]
|
tree: TreeNode[]
|
||||||
actions: KnowledgeViewActions
|
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
|
onOpenNote: (path: string) => void
|
||||||
onOpenGraph: () => void
|
onOpenGraph: () => void
|
||||||
onOpenSearch: () => void
|
onOpenSearch: () => void
|
||||||
|
|
@ -56,9 +60,48 @@ type KnowledgeViewProps = {
|
||||||
onVoiceNoteCreated?: (path: string) => void
|
onVoiceNoteCreated?: (path: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
type FlatRow = {
|
// Folders that have their own dedicated destinations elsewhere in the app.
|
||||||
node: TreeNode
|
const HIDDEN_PATHS = new Set(['knowledge/Meetings', 'knowledge/Workspace'])
|
||||||
depth: number
|
|
||||||
|
// 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[] {
|
function sortNodes(nodes: TreeNode[]): TreeNode[] {
|
||||||
|
|
@ -68,23 +111,22 @@ function sortNodes(nodes: TreeNode[]): TreeNode[] {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function flatten(
|
function findNode(nodes: TreeNode[], path: string): TreeNode | null {
|
||||||
nodes: TreeNode[],
|
for (const node of nodes) {
|
||||||
expanded: Set<string>,
|
if (node.path === path) return node
|
||||||
depth: number,
|
if (node.children) {
|
||||||
out: FlatRow[],
|
const found = findNode(node.children, path)
|
||||||
): void {
|
if (found) return found
|
||||||
for (const node of sortNodes(nodes)) {
|
|
||||||
out.push({ node, depth })
|
|
||||||
if (node.kind === 'dir' && expanded.has(node.path) && node.children?.length) {
|
|
||||||
flatten(node.children, expanded, depth + 1, out)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatModified(mtimeMs?: number): string {
|
function formatModified(mtimeMs?: number): string {
|
||||||
if (!mtimeMs) return ''
|
if (!mtimeMs) return ''
|
||||||
return formatRelativeTime(new Date(mtimeMs).toISOString())
|
const rel = formatRelativeTime(new Date(mtimeMs).toISOString())
|
||||||
|
if (!rel || rel === 'just now') return rel
|
||||||
|
return `${rel} ago`
|
||||||
}
|
}
|
||||||
|
|
||||||
function getFileManagerName(): string {
|
function getFileManagerName(): string {
|
||||||
|
|
@ -96,209 +138,607 @@ function getFileManagerName(): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
function displayName(node: TreeNode): string {
|
function displayName(node: TreeNode): string {
|
||||||
if (node.kind === 'file' && node.name.toLowerCase().endsWith('.md')) {
|
if (isMarkdown(node)) return node.name.slice(0, -3)
|
||||||
return node.name.slice(0, -3)
|
|
||||||
}
|
|
||||||
return node.name
|
return node.name
|
||||||
}
|
}
|
||||||
|
|
||||||
const INDENT_PX = 16
|
|
||||||
const ROW_PADDING_PX = 12
|
|
||||||
|
|
||||||
export function KnowledgeView({
|
export function KnowledgeView({
|
||||||
tree,
|
tree,
|
||||||
actions,
|
actions,
|
||||||
|
folderPath,
|
||||||
|
onNavigateFolder,
|
||||||
onOpenNote,
|
onOpenNote,
|
||||||
onOpenGraph,
|
onOpenGraph,
|
||||||
onOpenSearch,
|
onOpenSearch,
|
||||||
onOpenBases,
|
onOpenBases,
|
||||||
onVoiceNoteCreated,
|
onVoiceNoteCreated,
|
||||||
}: KnowledgeViewProps) {
|
}: KnowledgeViewProps) {
|
||||||
const [expanded, setExpanded] = useState<Set<string>>(new Set())
|
|
||||||
const [renameTarget, setRenameTarget] = useState<string | null>(null)
|
const [renameTarget, setRenameTarget] = useState<string | null>(null)
|
||||||
|
|
||||||
const rows = useMemo<FlatRow[]>(() => {
|
const topLevel = useMemo(
|
||||||
const out: FlatRow[] = []
|
() => tree.filter((n) => !HIDDEN_PATHS.has(n.path)),
|
||||||
// Meetings and Workspace have dedicated destinations, so hide them here.
|
[tree],
|
||||||
const visible = tree.filter((n) => n.path !== 'knowledge/Meetings' && n.path !== 'knowledge/Workspace')
|
|
||||||
flatten(visible, expanded, 0, out)
|
|
||||||
return out
|
|
||||||
}, [tree, expanded])
|
|
||||||
|
|
||||||
const handleRowClick = useCallback(
|
|
||||||
(node: TreeNode) => {
|
|
||||||
if (node.kind === 'dir') {
|
|
||||||
setExpanded((prev) => {
|
|
||||||
const next = new Set(prev)
|
|
||||||
if (next.has(node.path)) next.delete(node.path)
|
|
||||||
else next.add(node.path)
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
onOpenNote(node.path)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[onOpenNote],
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<div className="flex h-full flex-col overflow-hidden">
|
<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">
|
<div className="shrink-0 flex items-start justify-between gap-4 border-b border-border px-8 py-6">
|
||||||
<h1 className="text-2xl font-bold tracking-tight">Notes</h1>
|
<div className="min-w-0">
|
||||||
<div className="flex items-center gap-2">
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => actions.createNote()}
|
onClick={() => actions.createNote(currentFolder?.path)}
|
||||||
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"
|
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" />
|
<FilePlus className="size-4" />
|
||||||
<span>New note</span>
|
<span>New note</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={async () => {
|
|
||||||
try {
|
|
||||||
const path = await actions.createFolder()
|
|
||||||
setRenameTarget(path)
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
}}
|
|
||||||
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"
|
|
||||||
>
|
|
||||||
<FolderPlus className="size-4" />
|
|
||||||
<span>New folder</span>
|
|
||||||
</button>
|
|
||||||
<VoiceNoteButton onNoteCreated={onVoiceNoteCreated} />
|
|
||||||
<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>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onOpenBases}
|
|
||||||
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"
|
|
||||||
>
|
|
||||||
<Table2 className="size-4" />
|
|
||||||
<span>Bases</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onOpenGraph}
|
|
||||||
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"
|
|
||||||
>
|
|
||||||
<Network className="size-4" />
|
|
||||||
<span>Graph view</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => actions.revealInFileManager('knowledge', true)}
|
|
||||||
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"
|
|
||||||
>
|
|
||||||
<FolderOpen className="size-4" />
|
|
||||||
<span>Open in {getFileManagerName()}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
<div className="min-w-[480px]">
|
<div className="mx-auto w-full max-w-3xl px-8 py-6">
|
||||||
<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">
|
{currentFolder ? (
|
||||||
<div className="flex-1">Page name</div>
|
<FolderDetail
|
||||||
<div className="w-32 shrink-0">Modified</div>
|
folder={currentFolder}
|
||||||
</div>
|
actions={actions}
|
||||||
|
renameTarget={renameTarget}
|
||||||
{rows.length === 0 ? (
|
onRequestRename={setRenameTarget}
|
||||||
<div className="px-6 py-8 text-sm text-muted-foreground">No pages yet.</div>
|
onClearRename={() => setRenameTarget(null)}
|
||||||
|
onNavigate={onNavigateFolder}
|
||||||
|
onOpenFolder={openFolder}
|
||||||
|
onOpenNote={onOpenNote}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
rows.map(({ node, depth }) => (
|
<>
|
||||||
<KnowledgeRow
|
<SectionHeader label={`Folders · ${folders.length}`} aside="Sorted by name" />
|
||||||
key={node.path}
|
{folders.length === 0 ? (
|
||||||
node={node}
|
<EmptyState text="No folders yet." />
|
||||||
depth={depth}
|
) : (
|
||||||
isExpanded={expanded.has(node.path)}
|
<div className="overflow-hidden rounded-xl border border-border">
|
||||||
actions={actions}
|
{folders.map((node, i) => (
|
||||||
renameActive={renameTarget === node.path}
|
<div key={node.path} className={cn(i > 0 && 'border-t border-border/60')}>
|
||||||
onRequestRename={(p) => setRenameTarget(p)}
|
<FolderCard
|
||||||
onClearRename={() => setRenameTarget(null)}
|
node={node}
|
||||||
onClick={handleRowClick}
|
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function KnowledgeRow({
|
function QuickActions({
|
||||||
node,
|
|
||||||
depth,
|
|
||||||
isExpanded,
|
|
||||||
actions,
|
actions,
|
||||||
renameActive,
|
currentFolder,
|
||||||
onRequestRename,
|
onOpenBases,
|
||||||
onClearRename,
|
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,
|
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
|
node: TreeNode
|
||||||
depth: number
|
|
||||||
isExpanded: boolean
|
|
||||||
actions: KnowledgeViewActions
|
actions: KnowledgeViewActions
|
||||||
renameActive: boolean
|
renameTarget: string | null
|
||||||
onRequestRename: (path: string) => void
|
onRequestRename: (path: string) => void
|
||||||
onClearRename: () => void
|
onClearRename: () => void
|
||||||
onClick: (node: TreeNode) => 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 isDir = node.kind === 'dir'
|
||||||
const Icon = isDir ? FolderIcon : FileIcon
|
const renameActive = renameTarget === node.path
|
||||||
const paddingLeft = ROW_PADDING_PX + depth * INDENT_PX
|
const modified = formatModified(isDir ? latestMtime(node) : node.stat?.mtimeMs)
|
||||||
const baseName = displayName(node)
|
const count = useMemo(() => (isDir ? collectNotes(node).length : 0), [isDir, node])
|
||||||
|
|
||||||
const [newName, setNewName] = useState(baseName)
|
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 inputRef = useRef<HTMLInputElement | null>(null)
|
||||||
const isSubmittingRef = useRef(false)
|
const isSubmittingRef = useRef(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (renameActive) {
|
requestAnimationFrame(() => {
|
||||||
setNewName(baseName)
|
inputRef.current?.focus()
|
||||||
isSubmittingRef.current = false
|
inputRef.current?.select()
|
||||||
// focus on next tick after mount
|
})
|
||||||
requestAnimationFrame(() => {
|
}, [])
|
||||||
inputRef.current?.focus()
|
|
||||||
inputRef.current?.select()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}, [renameActive, baseName])
|
|
||||||
|
|
||||||
const handleRenameSubmit = useCallback(async () => {
|
const submit = useCallback(async () => {
|
||||||
if (isSubmittingRef.current) return
|
if (isSubmittingRef.current) return
|
||||||
isSubmittingRef.current = true
|
isSubmittingRef.current = true
|
||||||
const trimmed = newName.trim()
|
const trimmed = value.trim()
|
||||||
if (trimmed && trimmed !== baseName) {
|
if (trimmed && trimmed !== initial) {
|
||||||
try {
|
try {
|
||||||
await actions.rename(node.path, trimmed, isDir)
|
await actions.rename(path, trimmed, isDir)
|
||||||
toast('Renamed successfully', 'success')
|
toast('Renamed successfully', 'success')
|
||||||
} catch {
|
} catch {
|
||||||
toast('Failed to rename', 'error')
|
toast('Failed to rename', 'error')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
onClearRename()
|
onDone()
|
||||||
setTimeout(() => {
|
}, [actions, initial, isDir, onDone, path, value])
|
||||||
isSubmittingRef.current = false
|
|
||||||
}, 100)
|
|
||||||
}, [actions, baseName, isDir, newName, node.path, onClearRename])
|
|
||||||
|
|
||||||
const cancelRename = useCallback(() => {
|
const cancel = useCallback(() => {
|
||||||
isSubmittingRef.current = true
|
isSubmittingRef.current = true
|
||||||
setNewName(baseName)
|
onDone()
|
||||||
onClearRename()
|
}, [onDone])
|
||||||
setTimeout(() => {
|
|
||||||
isSubmittingRef.current = false
|
return (
|
||||||
}, 100)
|
<Input
|
||||||
}, [baseName, onClearRename])
|
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 () => {
|
const handleDelete = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -314,58 +754,9 @@ function KnowledgeRow({
|
||||||
toast('Path copied', 'success')
|
toast('Path copied', 'success')
|
||||||
}, [actions, node.path])
|
}, [actions, node.path])
|
||||||
|
|
||||||
const row = (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onClick(node)}
|
|
||||||
className="group flex w-full items-center border-b border-border/60 px-6 py-1.5 text-left text-sm transition-colors hover:bg-accent"
|
|
||||||
>
|
|
||||||
<div className="flex flex-1 items-center gap-1.5 min-w-0" style={{ paddingLeft }}>
|
|
||||||
<span className="inline-flex w-4 shrink-0 items-center justify-center text-muted-foreground">
|
|
||||||
{isDir ? (
|
|
||||||
<ChevronRight
|
|
||||||
className={cn(
|
|
||||||
'size-3.5 transition-transform',
|
|
||||||
isExpanded && 'rotate-90',
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</span>
|
|
||||||
<Icon className="size-4 shrink-0 text-muted-foreground" />
|
|
||||||
{renameActive ? (
|
|
||||||
<Input
|
|
||||||
ref={inputRef}
|
|
||||||
value={newName}
|
|
||||||
onChange={(e) => setNewName(e.target.value)}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
e.preventDefault()
|
|
||||||
void handleRenameSubmit()
|
|
||||||
} else if (e.key === 'Escape') {
|
|
||||||
e.preventDefault()
|
|
||||||
cancelRename()
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onBlur={() => {
|
|
||||||
if (!isSubmittingRef.current) void handleRenameSubmit()
|
|
||||||
}}
|
|
||||||
className="h-6 text-sm flex-1"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<span className="min-w-0 truncate">{baseName}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="w-32 shrink-0 text-xs text-muted-foreground tabular-nums">
|
|
||||||
{formatModified(node.stat?.mtimeMs)}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ContextMenu>
|
<ContextMenu>
|
||||||
<ContextMenuTrigger asChild>{row}</ContextMenuTrigger>
|
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
|
||||||
<ContextMenuContent className="w-48" onCloseAutoFocus={(e) => e.preventDefault()}>
|
<ContextMenuContent className="w-48" onCloseAutoFocus={(e) => e.preventDefault()}>
|
||||||
{isDir && (
|
{isDir && (
|
||||||
<>
|
<>
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ interface SearchResult {
|
||||||
path: string
|
path: string
|
||||||
}
|
}
|
||||||
|
|
||||||
type SearchType = 'knowledge' | 'chat'
|
export type SearchType = 'knowledge' | 'chat'
|
||||||
|
|
||||||
function activeTabToTypes(section: ActiveSection): SearchType[] {
|
function activeTabToTypes(section: ActiveSection): SearchType[] {
|
||||||
if (section === 'knowledge') return ['knowledge']
|
if (section === 'knowledge') return ['knowledge']
|
||||||
|
|
@ -46,6 +46,9 @@ interface CommandPaletteProps {
|
||||||
onOpenChange: (open: boolean) => void
|
onOpenChange: (open: boolean) => void
|
||||||
onSelectFile: (path: string) => void
|
onSelectFile: (path: string) => void
|
||||||
onSelectRun: (runId: string) => void
|
onSelectRun: (runId: string) => void
|
||||||
|
// Overrides the sidebar-section default for the initial scope (e.g. the
|
||||||
|
// knowledge view opens search scoped to knowledge).
|
||||||
|
defaultScope?: SearchType
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CommandPalette({
|
export function CommandPalette({
|
||||||
|
|
@ -53,6 +56,7 @@ export function CommandPalette({
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
onSelectFile,
|
onSelectFile,
|
||||||
onSelectRun,
|
onSelectRun,
|
||||||
|
defaultScope,
|
||||||
}: CommandPaletteProps) {
|
}: CommandPaletteProps) {
|
||||||
const { activeSection } = useSidebarSection()
|
const { activeSection } = useSidebarSection()
|
||||||
const searchInputRef = useRef<HTMLInputElement>(null)
|
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
@ -61,7 +65,7 @@ export function CommandPalette({
|
||||||
const [results, setResults] = useState<SearchResult[]>([])
|
const [results, setResults] = useState<SearchResult[]>([])
|
||||||
const [isSearching, setIsSearching] = useState(false)
|
const [isSearching, setIsSearching] = useState(false)
|
||||||
const [activeTypes, setActiveTypes] = useState<Set<SearchType>>(
|
const [activeTypes, setActiveTypes] = useState<Set<SearchType>>(
|
||||||
() => new Set(activeTabToTypes(activeSection))
|
() => new Set(defaultScope ? [defaultScope] : activeTabToTypes(activeSection))
|
||||||
)
|
)
|
||||||
const debouncedQuery = useDebounce(query, 250)
|
const debouncedQuery = useDebounce(query, 250)
|
||||||
|
|
||||||
|
|
@ -69,9 +73,9 @@ export function CommandPalette({
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
setQuery('')
|
setQuery('')
|
||||||
setActiveTypes(new Set(activeTabToTypes(activeSection)))
|
setActiveTypes(new Set(defaultScope ? [defaultScope] : activeTabToTypes(activeSection)))
|
||||||
}
|
}
|
||||||
}, [open, activeSection])
|
}, [open, activeSection, defaultScope])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { useState, useEffect, useCallback, useMemo } from "react"
|
import { useState, useEffect, useCallback, useMemo } from "react"
|
||||||
import { Server, Key, Shield, Palette, Monitor, Sun, Moon, Loader2, CheckCircle2, Plus, X, Wrench, Search, ChevronRight, Link2, Tags, Mail, BookOpen, User, Plug, HelpCircle, MessageCircle, Bug } from "lucide-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 {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
|
|
@ -25,8 +25,9 @@ import { useTheme } from "@/contexts/theme-context"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { AccountSettings } from "@/components/settings/account-settings"
|
import { AccountSettings } from "@/components/settings/account-settings"
|
||||||
import { ConnectedAccountsSettings } from "@/components/settings/connected-accounts-settings"
|
import { ConnectedAccountsSettings } from "@/components/settings/connected-accounts-settings"
|
||||||
|
import type { ApprovalPolicy } from "@x/shared/src/code-mode.js"
|
||||||
|
|
||||||
type ConfigTab = "account" | "connections" | "models" | "mcp" | "security" | "appearance" | "note-tagging" | "help"
|
type ConfigTab = "account" | "connections" | "models" | "mcp" | "security" | "code-mode" | "appearance" | "note-tagging" | "help"
|
||||||
|
|
||||||
interface TabConfig {
|
interface TabConfig {
|
||||||
id: ConfigTab
|
id: ConfigTab
|
||||||
|
|
@ -70,6 +71,12 @@ const tabs: TabConfig[] = [
|
||||||
path: "config/security.json",
|
path: "config/security.json",
|
||||||
description: "Configure allowed shell commands",
|
description: "Configure allowed shell commands",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "code-mode",
|
||||||
|
label: "Code Mode",
|
||||||
|
icon: Terminal,
|
||||||
|
description: "Delegate coding tasks to Claude Code or Codex",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "appearance",
|
id: "appearance",
|
||||||
label: "Appearance",
|
label: "Appearance",
|
||||||
|
|
@ -204,7 +211,7 @@ function ThemeOption({
|
||||||
}
|
}
|
||||||
|
|
||||||
function AppearanceSettings() {
|
function AppearanceSettings() {
|
||||||
const { theme, setTheme } = useTheme()
|
const { theme, setTheme, chatPanePlacement, setChatPanePlacement, chatPaneSize, setChatPaneSize } = useTheme()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
|
@ -234,6 +241,50 @@ function AppearanceSettings() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium mb-3">Chat</h4>
|
||||||
|
<p className="text-xs text-muted-foreground mb-4">
|
||||||
|
Choose where chat sits when another pane is open
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<ThemeOption
|
||||||
|
label="Chat right"
|
||||||
|
icon={PanelRight}
|
||||||
|
isSelected={chatPanePlacement === "right"}
|
||||||
|
onClick={() => setChatPanePlacement("right")}
|
||||||
|
/>
|
||||||
|
<ThemeOption
|
||||||
|
label="Chat middle"
|
||||||
|
icon={MessageCircle}
|
||||||
|
isSelected={chatPanePlacement === "middle"}
|
||||||
|
onClick={() => setChatPanePlacement("middle")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<h4 className="mt-6 text-sm font-medium mb-3">Chat size</h4>
|
||||||
|
<p className="text-xs text-muted-foreground mb-4">
|
||||||
|
Choose how much width chat gets when another pane is open
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<ThemeOption
|
||||||
|
label="Chat smaller"
|
||||||
|
icon={MessageCircle}
|
||||||
|
isSelected={chatPaneSize === "chat-smaller"}
|
||||||
|
onClick={() => setChatPaneSize("chat-smaller")}
|
||||||
|
/>
|
||||||
|
<ThemeOption
|
||||||
|
label="Chat equal"
|
||||||
|
icon={Monitor}
|
||||||
|
isSelected={chatPaneSize === "chat-equal"}
|
||||||
|
onClick={() => setChatPaneSize("chat-equal")}
|
||||||
|
/>
|
||||||
|
<ThemeOption
|
||||||
|
label="Chat bigger"
|
||||||
|
icon={PanelRight}
|
||||||
|
isSelected={chatPaneSize === "chat-bigger"}
|
||||||
|
onClick={() => setChatPaneSize("chat-bigger")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -271,17 +322,27 @@ const defaultBaseURLs: Partial<Record<LlmProviderFlavor, string>> = {
|
||||||
"openai-compatible": "http://localhost:1234/v1",
|
"openai-compatible": "http://localhost:1234/v1",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ProviderModelConfig = {
|
||||||
|
apiKey: string
|
||||||
|
baseURL: string
|
||||||
|
models: string[]
|
||||||
|
knowledgeGraphModel: string
|
||||||
|
meetingNotesModel: string
|
||||||
|
liveNoteAgentModel: string
|
||||||
|
autoPermissionDecisionModel: string
|
||||||
|
}
|
||||||
|
|
||||||
function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
||||||
const [provider, setProvider] = useState<LlmProviderFlavor>("openai")
|
const [provider, setProvider] = useState<LlmProviderFlavor>("openai")
|
||||||
const [defaultProvider, setDefaultProvider] = useState<LlmProviderFlavor | null>(null)
|
const [defaultProvider, setDefaultProvider] = useState<LlmProviderFlavor | null>(null)
|
||||||
const [providerConfigs, setProviderConfigs] = useState<Record<LlmProviderFlavor, { apiKey: string; baseURL: string; models: string[]; knowledgeGraphModel: string; meetingNotesModel: string; liveNoteAgentModel: string }>>({
|
const [providerConfigs, setProviderConfigs] = useState<Record<LlmProviderFlavor, ProviderModelConfig>>({
|
||||||
openai: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
|
openai: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
|
||||||
anthropic: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
|
anthropic: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
|
||||||
google: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
|
google: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
|
||||||
openrouter: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
|
openrouter: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
|
||||||
aigateway: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
|
aigateway: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
|
||||||
ollama: { apiKey: "", baseURL: "http://localhost:11434", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
|
ollama: { apiKey: "", baseURL: "http://localhost:11434", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
|
||||||
"openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
|
"openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
|
||||||
})
|
})
|
||||||
const [modelsCatalog, setModelsCatalog] = useState<Record<string, LlmModelOption[]>>({})
|
const [modelsCatalog, setModelsCatalog] = useState<Record<string, LlmModelOption[]>>({})
|
||||||
const [modelsLoading, setModelsLoading] = useState(false)
|
const [modelsLoading, setModelsLoading] = useState(false)
|
||||||
|
|
@ -307,7 +368,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
||||||
(!requiresBaseURL || activeConfig.baseURL.trim().length > 0)
|
(!requiresBaseURL || activeConfig.baseURL.trim().length > 0)
|
||||||
|
|
||||||
const updateConfig = useCallback(
|
const updateConfig = useCallback(
|
||||||
(prov: LlmProviderFlavor, updates: Partial<{ apiKey: string; baseURL: string; models: string[]; knowledgeGraphModel: string; meetingNotesModel: string; liveNoteAgentModel: string }>) => {
|
(prov: LlmProviderFlavor, updates: Partial<ProviderModelConfig>) => {
|
||||||
setProviderConfigs(prev => ({
|
setProviderConfigs(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
[prov]: { ...prev[prov], ...updates },
|
[prov]: { ...prev[prov], ...updates },
|
||||||
|
|
@ -382,6 +443,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
||||||
knowledgeGraphModel: e.knowledgeGraphModel || "",
|
knowledgeGraphModel: e.knowledgeGraphModel || "",
|
||||||
meetingNotesModel: e.meetingNotesModel || "",
|
meetingNotesModel: e.meetingNotesModel || "",
|
||||||
liveNoteAgentModel: e.liveNoteAgentModel || "",
|
liveNoteAgentModel: e.liveNoteAgentModel || "",
|
||||||
|
autoPermissionDecisionModel: e.autoPermissionDecisionModel || "",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -400,6 +462,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
||||||
knowledgeGraphModel: parsed.knowledgeGraphModel || "",
|
knowledgeGraphModel: parsed.knowledgeGraphModel || "",
|
||||||
meetingNotesModel: parsed.meetingNotesModel || "",
|
meetingNotesModel: parsed.meetingNotesModel || "",
|
||||||
liveNoteAgentModel: parsed.liveNoteAgentModel || "",
|
liveNoteAgentModel: parsed.liveNoteAgentModel || "",
|
||||||
|
autoPermissionDecisionModel: parsed.autoPermissionDecisionModel || "",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return next;
|
return next;
|
||||||
|
|
@ -475,6 +538,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
||||||
knowledgeGraphModel: activeConfig.knowledgeGraphModel.trim() || undefined,
|
knowledgeGraphModel: activeConfig.knowledgeGraphModel.trim() || undefined,
|
||||||
meetingNotesModel: activeConfig.meetingNotesModel.trim() || undefined,
|
meetingNotesModel: activeConfig.meetingNotesModel.trim() || undefined,
|
||||||
liveNoteAgentModel: activeConfig.liveNoteAgentModel.trim() || undefined,
|
liveNoteAgentModel: activeConfig.liveNoteAgentModel.trim() || undefined,
|
||||||
|
autoPermissionDecisionModel: activeConfig.autoPermissionDecisionModel.trim() || undefined,
|
||||||
}
|
}
|
||||||
const result = await window.ipc.invoke("models:test", providerConfig)
|
const result = await window.ipc.invoke("models:test", providerConfig)
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
|
|
@ -509,6 +573,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
||||||
knowledgeGraphModel: config.knowledgeGraphModel.trim() || undefined,
|
knowledgeGraphModel: config.knowledgeGraphModel.trim() || undefined,
|
||||||
meetingNotesModel: config.meetingNotesModel.trim() || undefined,
|
meetingNotesModel: config.meetingNotesModel.trim() || undefined,
|
||||||
liveNoteAgentModel: config.liveNoteAgentModel.trim() || undefined,
|
liveNoteAgentModel: config.liveNoteAgentModel.trim() || undefined,
|
||||||
|
autoPermissionDecisionModel: config.autoPermissionDecisionModel.trim() || undefined,
|
||||||
})
|
})
|
||||||
setDefaultProvider(prov)
|
setDefaultProvider(prov)
|
||||||
window.dispatchEvent(new Event('models-config-changed'))
|
window.dispatchEvent(new Event('models-config-changed'))
|
||||||
|
|
@ -540,6 +605,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
||||||
parsed.knowledgeGraphModel = defConfig.knowledgeGraphModel.trim() || undefined
|
parsed.knowledgeGraphModel = defConfig.knowledgeGraphModel.trim() || undefined
|
||||||
parsed.meetingNotesModel = defConfig.meetingNotesModel.trim() || undefined
|
parsed.meetingNotesModel = defConfig.meetingNotesModel.trim() || undefined
|
||||||
parsed.liveNoteAgentModel = defConfig.liveNoteAgentModel.trim() || undefined
|
parsed.liveNoteAgentModel = defConfig.liveNoteAgentModel.trim() || undefined
|
||||||
|
parsed.autoPermissionDecisionModel = defConfig.autoPermissionDecisionModel.trim() || undefined
|
||||||
}
|
}
|
||||||
await window.ipc.invoke("workspace:writeFile", {
|
await window.ipc.invoke("workspace:writeFile", {
|
||||||
path: "config/models.json",
|
path: "config/models.json",
|
||||||
|
|
@ -547,7 +613,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
||||||
})
|
})
|
||||||
setProviderConfigs(prev => ({
|
setProviderConfigs(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
[prov]: { apiKey: "", baseURL: defaultBaseURLs[prov] || "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "" },
|
[prov]: { apiKey: "", baseURL: defaultBaseURLs[prov] || "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", liveNoteAgentModel: "", autoPermissionDecisionModel: "" },
|
||||||
}))
|
}))
|
||||||
setTestState({ status: "idle" })
|
setTestState({ status: "idle" })
|
||||||
window.dispatchEvent(new Event('models-config-changed'))
|
window.dispatchEvent(new Event('models-config-changed'))
|
||||||
|
|
@ -805,6 +871,40 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
||||||
</Select>
|
</Select>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Auto-permission model */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Auto-permission model</span>
|
||||||
|
{modelsLoading ? (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
) : showModelInput ? (
|
||||||
|
<Input
|
||||||
|
value={activeConfig.autoPermissionDecisionModel}
|
||||||
|
onChange={(e) => updateConfig(provider, { autoPermissionDecisionModel: e.target.value })}
|
||||||
|
placeholder={primaryModel || "Enter model"}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Select
|
||||||
|
value={activeConfig.autoPermissionDecisionModel || "__same__"}
|
||||||
|
onValueChange={(value) => updateConfig(provider, { autoPermissionDecisionModel: value === "__same__" ? "" : value })}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a model" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__same__">Same as assistant</SelectItem>
|
||||||
|
{modelsForProvider.map((m) => (
|
||||||
|
<SelectItem key={m.id} value={m.id}>
|
||||||
|
{m.name || m.id}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* API Key */}
|
{/* API Key */}
|
||||||
|
|
@ -1648,6 +1748,245 @@ function NoteTaggingSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Code Mode Settings ---
|
||||||
|
|
||||||
|
type AgentStatus = { installed: boolean; signedIn: boolean }
|
||||||
|
type CodeModeAgentStatus = { claude: AgentStatus; codex: AgentStatus }
|
||||||
|
|
||||||
|
function AgentStatusRow({
|
||||||
|
name,
|
||||||
|
installLink,
|
||||||
|
signInCommand,
|
||||||
|
status,
|
||||||
|
}: {
|
||||||
|
name: string
|
||||||
|
installLink: string
|
||||||
|
signInCommand: string
|
||||||
|
status: AgentStatus | null
|
||||||
|
}) {
|
||||||
|
const ready = status?.installed && status?.signedIn
|
||||||
|
const needsSignInOnly = status?.installed && !status?.signedIn
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border px-3 py-2.5 flex items-center gap-3">
|
||||||
|
<Terminal className="size-4 text-muted-foreground shrink-0" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-sm font-medium">{name}</div>
|
||||||
|
<div className="text-xs text-muted-foreground mt-0.5 flex items-center gap-3">
|
||||||
|
<span className={cn("inline-flex items-center gap-1", status?.installed ? "text-green-600" : "text-muted-foreground")}>
|
||||||
|
{status?.installed ? <CheckCircle2 className="size-3" /> : <X className="size-3" />}
|
||||||
|
Installed
|
||||||
|
</span>
|
||||||
|
<span className={cn("inline-flex items-center gap-1", status?.signedIn ? "text-green-600" : "text-muted-foreground")}>
|
||||||
|
{status?.signedIn ? <CheckCircle2 className="size-3" /> : <X className="size-3" />}
|
||||||
|
Signed in
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{ready ? (
|
||||||
|
<span className="rounded-full bg-green-500/10 px-2 py-0.5 text-[10px] font-medium leading-none text-green-600">
|
||||||
|
Ready
|
||||||
|
</span>
|
||||||
|
) : needsSignInOnly ? (
|
||||||
|
<span className="text-xs text-muted-foreground shrink-0">
|
||||||
|
Run <code className="rounded bg-muted px-1 py-0.5 font-mono text-[11px] text-foreground">{signInCommand}</code>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<a
|
||||||
|
href={installLink}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-xs text-primary hover:underline shrink-0"
|
||||||
|
>
|
||||||
|
Install & sign in
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CodeModeSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
||||||
|
const [enabled, setEnabled] = useState(false)
|
||||||
|
const [approvalPolicy, setApprovalPolicy] = useState<ApprovalPolicy>('ask')
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [status, setStatus] = useState<CodeModeAgentStatus | null>(null)
|
||||||
|
const [statusLoading, setStatusLoading] = useState(false)
|
||||||
|
|
||||||
|
const loadStatus = useCallback(async () => {
|
||||||
|
setStatusLoading(true)
|
||||||
|
try {
|
||||||
|
const result = await window.ipc.invoke("codeMode:checkAgentStatus", null)
|
||||||
|
setStatus(result)
|
||||||
|
} catch {
|
||||||
|
setStatus(null)
|
||||||
|
} finally {
|
||||||
|
setStatusLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!dialogOpen) return
|
||||||
|
let cancelled = false
|
||||||
|
async function load() {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const result = await window.ipc.invoke("codeMode:getConfig", null)
|
||||||
|
if (!cancelled) {
|
||||||
|
setEnabled(result.enabled)
|
||||||
|
setApprovalPolicy(result.approvalPolicy ?? 'ask')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) setEnabled(false)
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
load()
|
||||||
|
loadStatus()
|
||||||
|
return () => { cancelled = true }
|
||||||
|
}, [dialogOpen, loadStatus])
|
||||||
|
|
||||||
|
const handleToggle = useCallback(async (next: boolean) => {
|
||||||
|
setSaving(true)
|
||||||
|
setEnabled(next)
|
||||||
|
try {
|
||||||
|
await window.ipc.invoke("codeMode:setConfig", { enabled: next, approvalPolicy })
|
||||||
|
window.dispatchEvent(new Event("code-mode-config-changed"))
|
||||||
|
toast.success(next ? "Code mode enabled" : "Code mode disabled")
|
||||||
|
} catch {
|
||||||
|
setEnabled(!next)
|
||||||
|
toast.error("Failed to update code mode")
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}, [approvalPolicy])
|
||||||
|
|
||||||
|
const handlePolicyChange = useCallback(async (next: ApprovalPolicy) => {
|
||||||
|
const prev = approvalPolicy
|
||||||
|
setSaving(true)
|
||||||
|
setApprovalPolicy(next)
|
||||||
|
try {
|
||||||
|
await window.ipc.invoke("codeMode:setConfig", { enabled, approvalPolicy: next })
|
||||||
|
window.dispatchEvent(new Event("code-mode-config-changed"))
|
||||||
|
} catch {
|
||||||
|
setApprovalPolicy(prev)
|
||||||
|
toast.error("Failed to update approval policy")
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}, [enabled, approvalPolicy])
|
||||||
|
|
||||||
|
const anyReady = status?.claude.installed && status?.claude.signedIn
|
||||||
|
|| status?.codex.installed && status?.codex.signedIn
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="h-full flex items-center justify-center text-muted-foreground text-sm">
|
||||||
|
<Loader2 className="size-4 animate-spin mr-2" />
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="space-y-2 text-sm text-muted-foreground leading-relaxed">
|
||||||
|
<p>
|
||||||
|
<strong className="text-foreground">Code mode</strong> lets the assistant delegate coding tasks
|
||||||
|
to <strong className="text-foreground">Claude Code</strong> or <strong className="text-foreground">Codex</strong> running
|
||||||
|
on your machine. Pick the agent inline from the composer; the assistant runs it on-device
|
||||||
|
and streams its work — tool calls, file diffs, and approvals — back into chat.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Requires an active <strong className="text-foreground">Claude Code</strong> subscription or
|
||||||
|
a <strong className="text-foreground">ChatGPT/Codex</strong> subscription. You can have one or both.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Agent status</span>
|
||||||
|
<button
|
||||||
|
onClick={() => { void loadStatus() }}
|
||||||
|
disabled={statusLoading}
|
||||||
|
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
{statusLoading ? <Loader2 className="size-3 animate-spin" /> : <RefreshCw className="size-3" />}
|
||||||
|
Re-check
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<AgentStatusRow
|
||||||
|
name="Claude Code"
|
||||||
|
installLink="https://claude.ai/code"
|
||||||
|
signInCommand="claude login"
|
||||||
|
status={status?.claude ?? null}
|
||||||
|
/>
|
||||||
|
<AgentStatusRow
|
||||||
|
name="Codex"
|
||||||
|
installLink="https://developers.openai.com/codex/cli"
|
||||||
|
signInCommand="codex login"
|
||||||
|
status={status?.codex ?? null}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-md border px-3 py-3 flex items-start gap-3">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-sm font-medium">Enable code mode</div>
|
||||||
|
<div className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
Shows the code mode chip in the composer and lets the assistant delegate to your installed agents.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={enabled}
|
||||||
|
onCheckedChange={handleToggle}
|
||||||
|
disabled={saving}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{enabled && (
|
||||||
|
<div className="rounded-md border px-3 py-3 space-y-2">
|
||||||
|
<div className="text-sm font-medium">Approvals</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
How the coding agent checks in before changing files or running commands. You always see
|
||||||
|
everything it does in the timeline — this only controls the prompts.
|
||||||
|
</div>
|
||||||
|
<Select
|
||||||
|
value={approvalPolicy}
|
||||||
|
onValueChange={(v) => handlePolicyChange(v as ApprovalPolicy)}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="ask">Ask every time</SelectItem>
|
||||||
|
<SelectItem value="auto-approve-reads">Auto-approve reads</SelectItem>
|
||||||
|
<SelectItem value="yolo">Auto-approve everything (YOLO)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{approvalPolicy === 'ask' && 'You approve every file change and command the agent wants to run.'}
|
||||||
|
{approvalPolicy === 'auto-approve-reads' && 'Reading and searching run automatically; you still approve writes, edits, and commands.'}
|
||||||
|
{approvalPolicy === 'yolo' && 'The agent runs everything — writes, edits, and commands — without asking. Use only in folders you trust.'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{enabled && status && !anyReady && (
|
||||||
|
<div className="rounded-md border border-amber-500/40 bg-amber-50/60 dark:bg-amber-950/20 px-3 py-2.5 flex items-start gap-2 text-xs">
|
||||||
|
<AlertTriangle className="size-4 text-amber-600 dark:text-amber-500 shrink-0 mt-0.5" />
|
||||||
|
<div className="text-amber-900 dark:text-amber-200">
|
||||||
|
Neither Claude Code nor Codex is ready. Install at least one and sign in with a subscription
|
||||||
|
account, then click Re-check.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// --- Main Settings Dialog ---
|
// --- Main Settings Dialog ---
|
||||||
|
|
||||||
export function SettingsDialog({ children, defaultTab = "account", open: controlledOpen, onOpenChange }: SettingsDialogProps) {
|
export function SettingsDialog({ children, defaultTab = "account", open: controlledOpen, onOpenChange }: SettingsDialogProps) {
|
||||||
|
|
@ -1695,7 +2034,7 @@ export function SettingsDialog({ children, defaultTab = "account", open: control
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadConfig = useCallback(async (tab: ConfigTab) => {
|
const loadConfig = useCallback(async (tab: ConfigTab) => {
|
||||||
if (tab === "appearance" || tab === "models" || tab === "note-tagging" || tab === "account" || tab === "connections" || tab === "help") return
|
if (tab === "appearance" || tab === "models" || tab === "note-tagging" || tab === "account" || tab === "connections" || tab === "help" || tab === "code-mode") return
|
||||||
const tabConfig = tabs.find((t) => t.id === tab)!
|
const tabConfig = tabs.find((t) => t.id === tab)!
|
||||||
if (!tabConfig.path) return
|
if (!tabConfig.path) return
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
|
|
@ -1803,7 +2142,7 @@ export function SettingsDialog({ children, defaultTab = "account", open: control
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className={cn("flex-1 p-4 min-h-0", (activeTab === "models" || activeTab === "connections" || activeTab === "account") ? "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 === "connections" || activeTab === "account" || activeTab === "code-mode") ? "overflow-y-auto" : activeTab === "note-tagging" ? "overflow-hidden flex flex-col" : "overflow-hidden")}>
|
||||||
{activeTab === "account" ? (
|
{activeTab === "account" ? (
|
||||||
<AccountSettings dialogOpen={open} />
|
<AccountSettings dialogOpen={open} />
|
||||||
) : activeTab === "connections" ? (
|
) : activeTab === "connections" ? (
|
||||||
|
|
@ -1828,6 +2167,8 @@ export function SettingsDialog({ children, defaultTab = "account", open: control
|
||||||
<AppearanceSettings />
|
<AppearanceSettings />
|
||||||
) : activeTab === "help" ? (
|
) : activeTab === "help" ? (
|
||||||
<HelpSettings />
|
<HelpSettings />
|
||||||
|
) : activeTab === "code-mode" ? (
|
||||||
|
<CodeModeSettings dialogOpen={open} />
|
||||||
) : loading ? (
|
) : loading ? (
|
||||||
<div className="h-full flex items-center justify-center text-muted-foreground text-sm">
|
<div className="h-full flex items-center justify-center text-muted-foreground text-sm">
|
||||||
Loading...
|
Loading...
|
||||||
|
|
|
||||||
|
|
@ -512,7 +512,7 @@ export function SidebarContentPanel({
|
||||||
const out: TreeNode[] = []
|
const out: TreeNode[] = []
|
||||||
const walk = (nodes: TreeNode[]) => {
|
const walk = (nodes: TreeNode[]) => {
|
||||||
for (const n of nodes) {
|
for (const n of nodes) {
|
||||||
if (n.path === 'knowledge/Meetings' || n.path === 'knowledge/Workspace') continue
|
if (n.path === 'knowledge/Meetings' || n.path === 'knowledge/Workspace' || n.path === 'knowledge/Agent Notes') continue
|
||||||
if (n.kind === 'file') out.push(n)
|
if (n.kind === 'file') out.push(n)
|
||||||
else if (n.children?.length) walk(n.children)
|
else if (n.children?.length) walk(n.children)
|
||||||
}
|
}
|
||||||
|
|
@ -521,11 +521,11 @@ export function SidebarContentPanel({
|
||||||
return out
|
return out
|
||||||
.filter((n) => n.stat?.mtimeMs)
|
.filter((n) => n.stat?.mtimeMs)
|
||||||
.sort((a, b) => (b.stat?.mtimeMs ?? 0) - (a.stat?.mtimeMs ?? 0))
|
.sort((a, b) => (b.stat?.mtimeMs ?? 0) - (a.stat?.mtimeMs ?? 0))
|
||||||
.slice(0, 5)
|
.slice(0, 10)
|
||||||
}, [tree])
|
}, [tree])
|
||||||
|
|
||||||
// Recents: most recently touched notes / agents / chats, interleaved by
|
// Recents: most recently touched notes / agents / chats, interleaved by
|
||||||
// recency. Capped per type (3 notes, 2 agents, 1 chat) and 5 overall.
|
// recency. Capped per type (4 notes, 4 agents, 4 chats) and 12 overall.
|
||||||
type QuickAccessItem = {
|
type QuickAccessItem = {
|
||||||
key: string
|
key: string
|
||||||
label: string
|
label: string
|
||||||
|
|
@ -536,7 +536,7 @@ export function SidebarContentPanel({
|
||||||
const quickAccessItems = React.useMemo<QuickAccessItem[]>(() => {
|
const quickAccessItems = React.useMemo<QuickAccessItem[]>(() => {
|
||||||
const items: QuickAccessItem[] = []
|
const items: QuickAccessItem[] = []
|
||||||
|
|
||||||
for (const note of recentNotes.slice(0, 3)) {
|
for (const note of recentNotes.slice(0, 4)) {
|
||||||
items.push({
|
items.push({
|
||||||
key: `note:${note.path}`,
|
key: `note:${note.path}`,
|
||||||
label: displayNoteName(note),
|
label: displayNoteName(note),
|
||||||
|
|
@ -551,7 +551,7 @@ export function SidebarContentPanel({
|
||||||
const ms = ts ? new Date(ts).getTime() : 0
|
const ms = ts ? new Date(ts).getTime() : 0
|
||||||
return Number.isFinite(ms) ? ms : 0
|
return Number.isFinite(ms) ? ms : 0
|
||||||
}
|
}
|
||||||
for (const t of [...bgTaskSummaries].sort((a, b) => agentRecency(b) - agentRecency(a)).slice(0, 2)) {
|
for (const t of [...bgTaskSummaries].sort((a, b) => agentRecency(b) - agentRecency(a)).slice(0, 4)) {
|
||||||
items.push({
|
items.push({
|
||||||
key: `agent:${t.slug}`,
|
key: `agent:${t.slug}`,
|
||||||
label: t.name,
|
label: t.name,
|
||||||
|
|
@ -565,7 +565,7 @@ export function SidebarContentPanel({
|
||||||
const ms = new Date(r.createdAt).getTime()
|
const ms = new Date(r.createdAt).getTime()
|
||||||
return Number.isFinite(ms) ? ms : 0
|
return Number.isFinite(ms) ? ms : 0
|
||||||
}
|
}
|
||||||
for (const r of [...recentRuns].sort((a, b) => chatRecency(b) - chatRecency(a)).slice(0, 1)) {
|
for (const r of [...recentRuns].sort((a, b) => chatRecency(b) - chatRecency(a)).slice(0, 4)) {
|
||||||
items.push({
|
items.push({
|
||||||
key: `chat:${r.id}`,
|
key: `chat:${r.id}`,
|
||||||
label: r.title || '(Untitled chat)',
|
label: r.title || '(Untitled chat)',
|
||||||
|
|
@ -575,7 +575,7 @@ export function SidebarContentPanel({
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return items.sort((a, b) => b.recency - a.recency).slice(0, 5)
|
return items.sort((a, b) => b.recency - a.recency).slice(0, 12)
|
||||||
}, [recentNotes, bgTaskSummaries, recentRuns, onSelectFile, onOpenAgent, onOpenRun])
|
}, [recentNotes, bgTaskSummaries, recentRuns, onSelectFile, onOpenAgent, onOpenRun])
|
||||||
|
|
||||||
// Workspace count for the Workspaces sublabel — top-level dir children of
|
// Workspace count for the Workspaces sublabel — top-level dir children of
|
||||||
|
|
@ -691,10 +691,20 @@ export function SidebarContentPanel({
|
||||||
// Single preview shown as a sublabel on the Email / Meetings nav buttons.
|
// Single preview shown as a sublabel on the Email / Meetings nav buttons.
|
||||||
const previewEmail = emailThreads[0]
|
const previewEmail = emailThreads[0]
|
||||||
const previewMeeting = meetings[0]
|
const previewMeeting = meetings[0]
|
||||||
const meetingIsRecording = previewMeeting != null
|
// Drive the recording indicator off the global recording state — there is only
|
||||||
&& recordingMeetingSource === previewMeeting.source
|
// one active recording, so it must show even for ad-hoc recordings or meetings
|
||||||
&& (meetingRecordingState === 'recording' || meetingRecordingState === 'connecting' || meetingRecordingState === 'stopping')
|
// that aren't the upcoming one previewed here.
|
||||||
const meetingIsBusy = meetingIsRecording && (meetingRecordingState === 'connecting' || meetingRecordingState === 'stopping')
|
const meetingIsRecording = meetingRecordingState === 'recording'
|
||||||
|
|| meetingRecordingState === 'connecting'
|
||||||
|
|| meetingRecordingState === 'stopping'
|
||||||
|
const meetingIsBusy = meetingRecordingState === 'connecting' || meetingRecordingState === 'stopping'
|
||||||
|
// Title of the meeting being recorded, when it's the upcoming one we preview.
|
||||||
|
const recordingMeeting = previewMeeting != null && recordingMeetingSource === previewMeeting.source
|
||||||
|
? previewMeeting
|
||||||
|
: null
|
||||||
|
const meetingSublabel = meetingIsRecording
|
||||||
|
? (recordingMeeting?.summary ?? 'Recording…')
|
||||||
|
: (previewMeeting ? `${previewMeeting.summary} · ${formatMeetingTime(previewMeeting)}` : null)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sidebar className="rowboat-sidebar border-r-0" {...props}>
|
<Sidebar className="rowboat-sidebar border-r-0" {...props}>
|
||||||
|
|
@ -750,19 +760,22 @@ export function SidebarContentPanel({
|
||||||
<SidebarMenuButton
|
<SidebarMenuButton
|
||||||
isActive={activeNav === 'meetings'}
|
isActive={activeNav === 'meetings'}
|
||||||
onClick={onOpenMeetings}
|
onClick={onOpenMeetings}
|
||||||
className={previewMeeting ? 'h-auto py-1.5' : undefined}
|
className={meetingSublabel ? 'h-auto py-1.5' : undefined}
|
||||||
>
|
>
|
||||||
<Mic className="size-4 shrink-0" />
|
<Mic className={cn('size-4 shrink-0', meetingIsRecording && 'text-red-500')} />
|
||||||
<div className="flex min-w-0 flex-1 flex-col">
|
<div className="flex min-w-0 flex-1 flex-col">
|
||||||
<span className="truncate">Meetings</span>
|
<span className="truncate">Meetings</span>
|
||||||
{previewMeeting && (
|
{meetingSublabel && (
|
||||||
<span className="truncate text-[11px] text-muted-foreground">
|
<span className={cn(
|
||||||
{meetingIsRecording ? previewMeeting.summary : `${previewMeeting.summary} · ${formatMeetingTime(previewMeeting)}`}
|
'truncate text-[11px]',
|
||||||
|
meetingIsRecording ? 'text-red-500' : 'text-muted-foreground',
|
||||||
|
)}>
|
||||||
|
{meetingSublabel}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
{previewMeeting && (meetingIsRecording ? (
|
{meetingIsRecording ? (
|
||||||
<div className="absolute inset-y-0 right-1 flex items-center gap-1.5">
|
<div className="absolute inset-y-0 right-1 flex items-center gap-1.5">
|
||||||
<span className="relative flex size-2">
|
<span className="relative flex size-2">
|
||||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-red-500 opacity-75" />
|
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-red-500 opacity-75" />
|
||||||
|
|
@ -786,7 +799,7 @@ export function SidebarContentPanel({
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : previewMeeting ? (
|
||||||
<div className="absolute inset-y-0 right-1 flex items-center gap-0.5 opacity-0 transition-opacity group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100">
|
<div className="absolute inset-y-0 right-1 flex items-center gap-0.5 opacity-0 transition-opacity group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100">
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
|
|
@ -819,7 +832,7 @@ export function SidebarContentPanel({
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
) : null}
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
<SidebarMenuButton
|
<SidebarMenuButton
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Copy,
|
Copy,
|
||||||
|
ExternalLink,
|
||||||
File as FileIcon,
|
File as FileIcon,
|
||||||
FilePlus,
|
FilePlus,
|
||||||
Folder as FolderIcon,
|
Folder as FolderIcon,
|
||||||
|
|
@ -53,12 +54,18 @@ type WorkspaceActions = {
|
||||||
remove: (path: string) => Promise<void>
|
remove: (path: string) => Promise<void>
|
||||||
copyPath: (path: string) => void
|
copyPath: (path: string) => void
|
||||||
revealInFileManager: (path: string, isDir: boolean) => void
|
revealInFileManager: (path: string, isDir: boolean) => void
|
||||||
|
createNote: (parentPath?: string) => void
|
||||||
|
createFolder: (parentPath?: string) => Promise<string>
|
||||||
|
onOpenInNewTab?: (path: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
type WorkspaceViewProps = {
|
type WorkspaceViewProps = {
|
||||||
tree: TreeNode[]
|
tree: TreeNode[]
|
||||||
initialPath?: string | null
|
initialPath?: string | null
|
||||||
actions: WorkspaceActions
|
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
|
onOpenNote: (path: string) => void
|
||||||
onCreateWorkspace: (name: string) => Promise<void>
|
onCreateWorkspace: (name: string) => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
@ -71,6 +78,12 @@ function getFileManagerName(): string {
|
||||||
return 'File Manager'
|
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 {
|
function findNode(nodes: TreeNode[] | undefined, path: string): TreeNode | null {
|
||||||
if (!nodes) return null
|
if (!nodes) return null
|
||||||
for (const node of nodes) {
|
for (const node of nodes) {
|
||||||
|
|
@ -113,8 +126,8 @@ function readFileAsBase64(file: File): Promise<string> {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WorkspaceView({ tree, initialPath, actions, onOpenNote, onCreateWorkspace }: WorkspaceViewProps) {
|
export function WorkspaceView({ tree, initialPath, actions, onNavigate, onOpenNote, onCreateWorkspace }: WorkspaceViewProps) {
|
||||||
const [currentPath, setCurrentPath] = useState<string>(initialPath || WORKSPACE_ROOT)
|
const currentPath = initialPath || WORKSPACE_ROOT
|
||||||
const [addOpen, setAddOpen] = useState(false)
|
const [addOpen, setAddOpen] = useState(false)
|
||||||
const [newName, setNewName] = useState('')
|
const [newName, setNewName] = useState('')
|
||||||
const [creating, setCreating] = useState(false)
|
const [creating, setCreating] = useState(false)
|
||||||
|
|
@ -127,10 +140,6 @@ export function WorkspaceView({ tree, initialPath, actions, onOpenNote, onCreate
|
||||||
const filesInputRef = useRef<HTMLInputElement | null>(null)
|
const filesInputRef = useRef<HTMLInputElement | null>(null)
|
||||||
const folderInputRef = useRef<HTMLInputElement | null>(null)
|
const folderInputRef = useRef<HTMLInputElement | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (initialPath) setCurrentPath(initialPath)
|
|
||||||
}, [initialPath])
|
|
||||||
|
|
||||||
const isRoot = currentPath === WORKSPACE_ROOT
|
const isRoot = currentPath === WORKSPACE_ROOT
|
||||||
const fileManagerName = getFileManagerName()
|
const fileManagerName = getFileManagerName()
|
||||||
|
|
||||||
|
|
@ -160,12 +169,12 @@ export function WorkspaceView({ tree, initialPath, actions, onOpenNote, onCreate
|
||||||
(item: TreeNode) => {
|
(item: TreeNode) => {
|
||||||
if (renameTarget) return
|
if (renameTarget) return
|
||||||
if (item.kind === 'dir') {
|
if (item.kind === 'dir') {
|
||||||
setCurrentPath(item.path)
|
onNavigate(item.path)
|
||||||
} else {
|
} else {
|
||||||
onOpenNote(item.path)
|
onOpenNote(item.path)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[onOpenNote, renameTarget],
|
[onNavigate, onOpenNote, renameTarget],
|
||||||
)
|
)
|
||||||
|
|
||||||
const beginRename = useCallback((item: TreeNode) => {
|
const beginRename = useCallback((item: TreeNode) => {
|
||||||
|
|
@ -295,7 +304,7 @@ export function WorkspaceView({ tree, initialPath, actions, onOpenNote, onCreate
|
||||||
<div className="flex min-w-0 items-center gap-1 text-sm">
|
<div className="flex min-w-0 items-center gap-1 text-sm">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setCurrentPath(WORKSPACE_ROOT)}
|
onClick={() => onNavigate(WORKSPACE_ROOT)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'inline-flex items-center gap-1.5 rounded-md px-2 py-1 transition-colors',
|
'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',
|
isRoot ? 'text-foreground' : 'text-muted-foreground hover:text-foreground hover:bg-accent',
|
||||||
|
|
@ -316,7 +325,7 @@ export function WorkspaceView({ tree, initialPath, actions, onOpenNote, onCreate
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setCurrentPath(crumb.path)}
|
onClick={() => onNavigate(crumb.path)}
|
||||||
className="rounded-md px-2 py-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground truncate"
|
className="rounded-md px-2 py-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground truncate"
|
||||||
>
|
>
|
||||||
{crumb.name}
|
{crumb.name}
|
||||||
|
|
@ -326,31 +335,42 @@ export function WorkspaceView({ tree, initialPath, actions, onOpenNote, onCreate
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
{isRoot ? (
|
<div className="grid shrink-0 grid-cols-2 items-center gap-2">
|
||||||
<Button size="sm" onClick={() => setAddOpen(true)}>
|
<Button
|
||||||
<Plus className="size-4" />
|
size="sm"
|
||||||
Add workspace
|
variant="outline"
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => actions.revealInFileManager(currentPath, true)}
|
||||||
|
>
|
||||||
|
<FolderOpen className="size-4" />
|
||||||
|
Open in {fileManagerName}
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
{isRoot ? (
|
||||||
<DropdownMenu>
|
<Button size="sm" className="w-full" onClick={() => setAddOpen(true)}>
|
||||||
<DropdownMenuTrigger asChild>
|
<Plus className="size-4" />
|
||||||
<Button size="sm">
|
Add workspace
|
||||||
<Plus className="size-4" />
|
</Button>
|
||||||
Add
|
) : (
|
||||||
</Button>
|
<DropdownMenu>
|
||||||
</DropdownMenuTrigger>
|
<DropdownMenuTrigger asChild>
|
||||||
<DropdownMenuContent align="end">
|
<Button size="sm" className="w-full">
|
||||||
<DropdownMenuItem onClick={() => filesInputRef.current?.click()}>
|
<Plus className="size-4" />
|
||||||
<FilePlus className="mr-2 size-4" />
|
Add
|
||||||
Add files…
|
</Button>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuItem onClick={() => folderInputRef.current?.click()}>
|
<DropdownMenuContent align="end">
|
||||||
<FolderPlus className="mr-2 size-4" />
|
<DropdownMenuItem onClick={() => filesInputRef.current?.click()}>
|
||||||
Add folder…
|
<FilePlus className="mr-2 size-4" />
|
||||||
</DropdownMenuItem>
|
Add files…
|
||||||
</DropdownMenuContent>
|
</DropdownMenuItem>
|
||||||
</DropdownMenu>
|
<DropdownMenuItem onClick={() => folderInputRef.current?.click()}>
|
||||||
)}
|
<FolderPlus className="mr-2 size-4" />
|
||||||
|
Add folder…
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
ref={filesInputRef}
|
ref={filesInputRef}
|
||||||
|
|
@ -429,31 +449,56 @@ export function WorkspaceView({ tree, initialPath, actions, onOpenNote, onCreate
|
||||||
) : (
|
) : (
|
||||||
<div className="truncate text-sm font-medium">{item.name}</div>
|
<div className="truncate text-sm font-medium">{item.name}</div>
|
||||||
)}
|
)}
|
||||||
{item.kind === 'dir' && !isRenaming && (
|
{!isRenaming && (
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="truncate text-xs text-muted-foreground">
|
||||||
{childCount} {childCount === 1 ? 'item' : 'items'}
|
{item.kind === 'dir'
|
||||||
|
? `${childCount} ${childCount === 1 ? 'item' : 'items'}`
|
||||||
|
: fileExtensionLabel(item.name)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
|
const isDir = item.kind === 'dir'
|
||||||
return (
|
return (
|
||||||
<ContextMenu key={item.path}>
|
<ContextMenu key={item.path}>
|
||||||
<ContextMenuTrigger asChild>{card}</ContextMenuTrigger>
|
<ContextMenuTrigger asChild>{card}</ContextMenuTrigger>
|
||||||
<ContextMenuContent className="w-48">
|
<ContextMenuContent className="w-48" onCloseAutoFocus={(e) => e.preventDefault()}>
|
||||||
<ContextMenuItem onClick={() => beginRename(item)}>
|
{isDir && (
|
||||||
<Pencil className="mr-2 size-4" />
|
<>
|
||||||
Rename
|
<ContextMenuItem onClick={() => actions.createNote(item.path)}>
|
||||||
</ContextMenuItem>
|
<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') }}>
|
<ContextMenuItem onClick={() => { actions.copyPath(item.path); toast('Path copied', 'success') }}>
|
||||||
<Copy className="mr-2 size-4" />
|
<Copy className="mr-2 size-4" />
|
||||||
Copy Path
|
Copy Path
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
<ContextMenuItem onClick={() => actions.revealInFileManager(item.path, item.kind === 'dir')}>
|
<ContextMenuItem onClick={() => actions.revealInFileManager(item.path, isDir)}>
|
||||||
<FolderOpen className="mr-2 size-4" />
|
<FolderOpen className="mr-2 size-4" />
|
||||||
Show in {fileManagerName}
|
Open in {fileManagerName}
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
<ContextMenuSeparator />
|
<ContextMenuSeparator />
|
||||||
|
<ContextMenuItem onClick={() => beginRename(item)}>
|
||||||
|
<Pencil className="mr-2 size-4" />
|
||||||
|
Rename
|
||||||
|
</ContextMenuItem>
|
||||||
<ContextMenuItem variant="destructive" onClick={() => void handleDelete(item)}>
|
<ContextMenuItem variant="destructive" onClick={() => void handleDelete(item)}>
|
||||||
<Trash2 className="mr-2 size-4" />
|
<Trash2 className="mr-2 size-4" />
|
||||||
Delete
|
Delete
|
||||||
|
|
|
||||||
|
|
@ -3,16 +3,32 @@
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
|
|
||||||
export type Theme = "light" | "dark" | "system"
|
export type Theme = "light" | "dark" | "system"
|
||||||
|
export type ChatPanePlacement = "right" | "middle"
|
||||||
|
export type ChatPaneSize = "chat-smaller" | "chat-equal" | "chat-bigger"
|
||||||
|
|
||||||
type ThemeContextProps = {
|
type ThemeContextProps = {
|
||||||
theme: Theme
|
theme: Theme
|
||||||
resolvedTheme: "light" | "dark"
|
resolvedTheme: "light" | "dark"
|
||||||
setTheme: (theme: Theme) => void
|
setTheme: (theme: Theme) => void
|
||||||
|
chatPanePlacement: ChatPanePlacement
|
||||||
|
setChatPanePlacement: (placement: ChatPanePlacement) => void
|
||||||
|
chatPaneSize: ChatPaneSize
|
||||||
|
setChatPaneSize: (size: ChatPaneSize) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const ThemeContext = React.createContext<ThemeContextProps | null>(null)
|
const ThemeContext = React.createContext<ThemeContextProps | null>(null)
|
||||||
|
|
||||||
const STORAGE_KEY = "rowboat-theme"
|
const STORAGE_KEY = "rowboat-theme"
|
||||||
|
const CHAT_PANE_PLACEMENT_STORAGE_KEY = "rowboat-chat-pane-placement"
|
||||||
|
const CHAT_PANE_SIZE_STORAGE_KEY = "rowboat-chat-pane-size"
|
||||||
|
|
||||||
|
function isChatPanePlacement(value: string | null): value is ChatPanePlacement {
|
||||||
|
return value === "right" || value === "middle"
|
||||||
|
}
|
||||||
|
|
||||||
|
function isChatPaneSize(value: string | null): value is ChatPaneSize {
|
||||||
|
return value === "chat-smaller" || value === "chat-equal" || value === "chat-bigger"
|
||||||
|
}
|
||||||
|
|
||||||
function getSystemTheme(): "light" | "dark" {
|
function getSystemTheme(): "light" | "dark" {
|
||||||
if (typeof window === "undefined") return "light"
|
if (typeof window === "undefined") return "light"
|
||||||
|
|
@ -39,6 +55,16 @@ export function ThemeProvider({
|
||||||
const stored = localStorage.getItem(STORAGE_KEY) as Theme | null
|
const stored = localStorage.getItem(STORAGE_KEY) as Theme | null
|
||||||
return stored || defaultTheme
|
return stored || defaultTheme
|
||||||
})
|
})
|
||||||
|
const [chatPanePlacement, setChatPanePlacementState] = React.useState<ChatPanePlacement>(() => {
|
||||||
|
if (typeof window === "undefined") return "right"
|
||||||
|
const stored = localStorage.getItem(CHAT_PANE_PLACEMENT_STORAGE_KEY)
|
||||||
|
return isChatPanePlacement(stored) ? stored : "right"
|
||||||
|
})
|
||||||
|
const [chatPaneSize, setChatPaneSizeState] = React.useState<ChatPaneSize>(() => {
|
||||||
|
if (typeof window === "undefined") return "chat-smaller"
|
||||||
|
const stored = localStorage.getItem(CHAT_PANE_SIZE_STORAGE_KEY)
|
||||||
|
return isChatPaneSize(stored) ? stored : "chat-smaller"
|
||||||
|
})
|
||||||
|
|
||||||
const [resolvedTheme, setResolvedTheme] = React.useState<"light" | "dark">(() => {
|
const [resolvedTheme, setResolvedTheme] = React.useState<"light" | "dark">(() => {
|
||||||
if (theme === "system") return getSystemTheme()
|
if (theme === "system") return getSystemTheme()
|
||||||
|
|
@ -76,13 +102,27 @@ export function ThemeProvider({
|
||||||
setThemeState(newTheme)
|
setThemeState(newTheme)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const setChatPanePlacement = React.useCallback((placement: ChatPanePlacement) => {
|
||||||
|
localStorage.setItem(CHAT_PANE_PLACEMENT_STORAGE_KEY, placement)
|
||||||
|
setChatPanePlacementState(placement)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const setChatPaneSize = React.useCallback((size: ChatPaneSize) => {
|
||||||
|
localStorage.setItem(CHAT_PANE_SIZE_STORAGE_KEY, size)
|
||||||
|
setChatPaneSizeState(size)
|
||||||
|
}, [])
|
||||||
|
|
||||||
const contextValue = React.useMemo<ThemeContextProps>(
|
const contextValue = React.useMemo<ThemeContextProps>(
|
||||||
() => ({
|
() => ({
|
||||||
theme,
|
theme,
|
||||||
resolvedTheme,
|
resolvedTheme,
|
||||||
setTheme,
|
setTheme,
|
||||||
|
chatPanePlacement,
|
||||||
|
setChatPanePlacement,
|
||||||
|
chatPaneSize,
|
||||||
|
setChatPaneSize,
|
||||||
}),
|
}),
|
||||||
[theme, resolvedTheme, setTheme]
|
[theme, resolvedTheme, setTheme, chatPanePlacement, setChatPanePlacement, chatPaneSize, setChatPaneSize]
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
import posthog from 'posthog-js'
|
import posthog from 'posthog-js'
|
||||||
|
import { identifyUser, resetAnalyticsIdentity } from '@/lib/analytics'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Identifies the user in PostHog when signed into Rowboat,
|
* Identifies the user in PostHog when signed into Rowboat,
|
||||||
|
|
@ -17,7 +18,7 @@ export function useAnalyticsIdentity() {
|
||||||
// Identify if Rowboat account is connected
|
// Identify if Rowboat account is connected
|
||||||
const rowboat = config.rowboat
|
const rowboat = config.rowboat
|
||||||
if (rowboat?.connected && rowboat?.userId) {
|
if (rowboat?.connected && rowboat?.userId) {
|
||||||
posthog.identify(rowboat.userId)
|
identifyUser(rowboat.userId)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set provider connection flags
|
// Set provider connection flags
|
||||||
|
|
@ -69,7 +70,7 @@ export function useAnalyticsIdentity() {
|
||||||
// Rowboat sign-in
|
// Rowboat sign-in
|
||||||
if (event.success) {
|
if (event.success) {
|
||||||
if (event.userId) {
|
if (event.userId) {
|
||||||
posthog.identify(event.userId)
|
identifyUser(event.userId)
|
||||||
}
|
}
|
||||||
posthog.people.set({ signed_in: true, rowboat_connected: true })
|
posthog.people.set({ signed_in: true, rowboat_connected: true })
|
||||||
posthog.capture('user_signed_in')
|
posthog.capture('user_signed_in')
|
||||||
|
|
@ -80,7 +81,7 @@ export function useAnalyticsIdentity() {
|
||||||
// future events on this device don't get attributed to the prior user.
|
// future events on this device don't get attributed to the prior user.
|
||||||
posthog.people.set({ signed_in: false, rowboat_connected: false })
|
posthog.people.set({ signed_in: false, rowboat_connected: false })
|
||||||
posthog.capture('user_signed_out')
|
posthog.capture('user_signed_out')
|
||||||
posthog.reset()
|
resetAnalyticsIdentity()
|
||||||
})
|
})
|
||||||
|
|
||||||
return cleanup
|
return cleanup
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,42 @@
|
||||||
import posthog from 'posthog-js'
|
import posthog from 'posthog-js'
|
||||||
|
|
||||||
|
let appVersion: string | undefined
|
||||||
|
let apiUrl: string | undefined
|
||||||
|
|
||||||
|
function appVersionProperties(): Record<string, string> {
|
||||||
|
return appVersion ? { app_version: appVersion } : {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function configureAnalyticsContext(props: { appVersion?: string; apiUrl?: string }) {
|
||||||
|
appVersion = props.appVersion?.trim() || undefined
|
||||||
|
apiUrl = props.apiUrl?.trim() || undefined
|
||||||
|
|
||||||
|
const eventProperties = appVersionProperties()
|
||||||
|
if (Object.keys(eventProperties).length > 0) {
|
||||||
|
posthog.register(eventProperties)
|
||||||
|
}
|
||||||
|
|
||||||
|
const personProperties = {
|
||||||
|
...(apiUrl ? { api_url: apiUrl } : {}),
|
||||||
|
...eventProperties,
|
||||||
|
}
|
||||||
|
if (Object.keys(personProperties).length > 0) {
|
||||||
|
posthog.people.set(personProperties)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function identifyUser(userId: string, properties?: Record<string, unknown>) {
|
||||||
|
posthog.identify(userId, {
|
||||||
|
...properties,
|
||||||
|
...appVersionProperties(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetAnalyticsIdentity() {
|
||||||
|
posthog.reset()
|
||||||
|
configureAnalyticsContext({ appVersion, apiUrl })
|
||||||
|
}
|
||||||
|
|
||||||
export function chatSessionCreated(runId: string) {
|
export function chatSessionCreated(runId: string) {
|
||||||
posthog.capture('chat_session_created', { run_id: runId })
|
posthog.capture('chat_session_created', { run_id: runId })
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import type { ToolUIPart } from 'ai'
|
import type { ToolUIPart } from 'ai'
|
||||||
import z from 'zod'
|
import z from 'zod'
|
||||||
import { AskHumanRequestEvent, ToolPermissionRequestEvent } from '@x/shared/src/runs.js'
|
import { AskHumanRequestEvent, ToolPermissionAutoDecisionEvent, ToolPermissionRequestEvent } from '@x/shared/src/runs.js'
|
||||||
import { COMPOSIO_DISPLAY_NAMES } from '@x/shared/src/composio.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 {
|
export interface MessageAttachment {
|
||||||
path: string
|
path: string
|
||||||
|
|
@ -27,6 +28,9 @@ export interface ToolCall {
|
||||||
streamingOutput?: string
|
streamingOutput?: string
|
||||||
status: 'pending' | 'running' | 'completed' | 'error'
|
status: 'pending' | 'running' | 'completed' | 'error'
|
||||||
timestamp: number
|
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 {
|
export interface ErrorMessage {
|
||||||
|
|
@ -46,6 +50,7 @@ export type ChatTabViewState = {
|
||||||
pendingAskHumanRequests: Map<string, z.infer<typeof AskHumanRequestEvent>>
|
pendingAskHumanRequests: Map<string, z.infer<typeof AskHumanRequestEvent>>
|
||||||
allPermissionRequests: Map<string, z.infer<typeof ToolPermissionRequestEvent>>
|
allPermissionRequests: Map<string, z.infer<typeof ToolPermissionRequestEvent>>
|
||||||
permissionResponses: Map<string, PermissionResponse>
|
permissionResponses: Map<string, PermissionResponse>
|
||||||
|
autoPermissionDecisions: Map<string, z.infer<typeof ToolPermissionAutoDecisionEvent>>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ChatViewportAnchorState = {
|
export type ChatViewportAnchorState = {
|
||||||
|
|
@ -60,6 +65,7 @@ export const createEmptyChatTabViewState = (): ChatTabViewState => ({
|
||||||
pendingAskHumanRequests: new Map(),
|
pendingAskHumanRequests: new Map(),
|
||||||
allPermissionRequests: new Map(),
|
allPermissionRequests: new Map(),
|
||||||
permissionResponses: new Map(),
|
permissionResponses: new Map(),
|
||||||
|
autoPermissionDecisions: new Map(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export type ToolState = 'input-streaming' | 'input-available' | 'output-available' | 'output-error'
|
export type ToolState = 'input-streaming' | 'input-available' | 'output-available' | 'output-error'
|
||||||
|
|
@ -600,6 +606,7 @@ export const isToolGroup = (item: GroupedConversationItem): item is ToolGroup =>
|
||||||
|
|
||||||
const isPlainToolCall = (item: ConversationItem): item is ToolCall => {
|
const isPlainToolCall = (item: ConversationItem): item is ToolCall => {
|
||||||
if (!isToolCall(item)) return false
|
if (!isToolCall(item)) return false
|
||||||
|
if (item.name === 'code_agent_run') return false // rich standalone block, never grouped
|
||||||
if (getWebSearchCardData(item)) return false
|
if (getWebSearchCardData(item)) return false
|
||||||
if (getComposioConnectCardData(item)) return false
|
if (getComposioConnectCardData(item)) return false
|
||||||
if (getAppActionCardData(item)) return false
|
if (getAppActionCardData(item)) return false
|
||||||
|
|
@ -653,6 +660,63 @@ export const getToolGroupSummary = (tools: ToolCall[]): string => {
|
||||||
return names.join(' · ')
|
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 => {
|
export const inferRunTitleFromMessage = (content: string): string | undefined => {
|
||||||
const { message } = parseAttachedFiles(content)
|
const { message } = parseAttachedFiles(content)
|
||||||
const normalized = message.replace(/\s+/g, ' ').trim()
|
const normalized = message.replace(/\s+/g, ' ').trim()
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
* also uses it to decide what to keep mounted.
|
* also uses it to decide what to keep mounted.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export type ViewerType = 'html' | 'image' | 'video' | 'audio' | 'pdf'
|
export type ViewerType = 'html' | 'image' | 'video' | 'audio' | 'pdf' | 'docx'
|
||||||
|
|
||||||
const VIEWER_BY_EXT: Record<string, ViewerType> = {
|
const VIEWER_BY_EXT: Record<string, ViewerType> = {
|
||||||
html: 'html',
|
html: 'html',
|
||||||
|
|
@ -31,6 +31,7 @@ const VIEWER_BY_EXT: Record<string, ViewerType> = {
|
||||||
flac: 'audio',
|
flac: 'audio',
|
||||||
aac: 'audio',
|
aac: 'audio',
|
||||||
pdf: 'pdf',
|
pdf: 'pdf',
|
||||||
|
docx: 'docx',
|
||||||
}
|
}
|
||||||
|
|
||||||
function extensionOf(path: string): string {
|
function extensionOf(path: string): string {
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,10 @@ import { StrictMode } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
import App from './App.tsx'
|
import App from './App.tsx'
|
||||||
import posthog from 'posthog-js'
|
|
||||||
import { PostHogProvider } from 'posthog-js/react'
|
import { PostHogProvider } from 'posthog-js/react'
|
||||||
|
import type { CaptureResult } from 'posthog-js'
|
||||||
import { ThemeProvider } from '@/contexts/theme-context'
|
import { ThemeProvider } from '@/contexts/theme-context'
|
||||||
|
import { configureAnalyticsContext } from './lib/analytics'
|
||||||
|
|
||||||
// Fetch the stable installation ID from main so renderer + main share one
|
// Fetch the stable installation ID from main so renderer + main share one
|
||||||
// PostHog distinct_id. Falls back to PostHog's auto-generated anonymous ID
|
// PostHog distinct_id. Falls back to PostHog's auto-generated anonymous ID
|
||||||
|
|
@ -12,19 +13,36 @@ import { ThemeProvider } from '@/contexts/theme-context'
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
let installationId: string | undefined
|
let installationId: string | undefined
|
||||||
let apiUrl: string | undefined
|
let apiUrl: string | undefined
|
||||||
|
let appVersion: string | undefined
|
||||||
try {
|
try {
|
||||||
const result = await window.ipc.invoke('analytics:bootstrap', null)
|
const result = await window.ipc.invoke('analytics:bootstrap', null)
|
||||||
installationId = result.installationId
|
installationId = result.installationId
|
||||||
apiUrl = result.apiUrl
|
apiUrl = result.apiUrl
|
||||||
|
appVersion = result.appVersion
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[Analytics] Failed to bootstrap from main:', err)
|
console.error('[Analytics] Failed to bootstrap from main:', err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
configureAnalyticsContext({ apiUrl, appVersion })
|
||||||
|
|
||||||
const options = {
|
const options = {
|
||||||
api_host: import.meta.env.VITE_PUBLIC_POSTHOG_HOST,
|
api_host: import.meta.env.VITE_PUBLIC_POSTHOG_HOST,
|
||||||
defaults: '2025-11-30',
|
defaults: '2025-11-30' as const,
|
||||||
...(installationId ? { bootstrap: { distinctID: installationId } } : {}),
|
...(installationId ? { bootstrap: { distinctID: installationId } } : {}),
|
||||||
} as const
|
before_send: (event: CaptureResult | null) => {
|
||||||
|
if (!event) return event
|
||||||
|
if (appVersion) {
|
||||||
|
event.properties = {
|
||||||
|
...event.properties,
|
||||||
|
app_version: appVersion,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return event
|
||||||
|
},
|
||||||
|
loaded: () => {
|
||||||
|
configureAnalyticsContext({ apiUrl, appVersion })
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
|
|
@ -36,11 +54,7 @@ async function bootstrap() {
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Tag the active person record with api_url so anonymous users are also
|
// The loaded callback applies api_url/app_version once PostHog has initialized.
|
||||||
// segmentable by environment.
|
|
||||||
if (apiUrl) {
|
|
||||||
posthog.people.set({ api_url: apiUrl })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
bootstrap()
|
bootstrap()
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,9 @@
|
||||||
"test:watch": "vitest"
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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/anthropic": "^2.0.63",
|
||||||
"@ai-sdk/google": "^2.0.53",
|
"@ai-sdk/google": "^2.0.53",
|
||||||
"@ai-sdk/openai": "^2.0.91",
|
"@ai-sdk/openai": "^2.0.91",
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import fs from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { WorkDir } from "../config/config.js";
|
import { WorkDir } from "../config/config.js";
|
||||||
import { Agent, ToolAttachment } from "@x/shared/dist/agent.js";
|
import { Agent, ToolAttachment } from "@x/shared/dist/agent.js";
|
||||||
import { AssistantContentPart, AssistantMessage, Message, MessageList, ProviderOptions, ToolCallPart, ToolMessage } from "@x/shared/dist/message.js";
|
import { AssistantContentPart, AssistantMessage, Message, MessageList, ProviderOptions, ToolCallPart, ToolMessage, UserMessageContext } from "@x/shared/dist/message.js";
|
||||||
import { LanguageModel, stepCountIs, streamText, tool, Tool, ToolSet } from "ai";
|
import { LanguageModel, stepCountIs, streamText, tool, Tool, ToolSet } from "ai";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { LlmStepStreamEvent } from "@x/shared/dist/llm-step-events.js";
|
import { LlmStepStreamEvent } from "@x/shared/dist/llm-step-events.js";
|
||||||
|
|
@ -23,7 +23,7 @@ import { resolveProviderConfig } from "../models/defaults.js";
|
||||||
import { IAgentsRepo } from "./repo.js";
|
import { IAgentsRepo } from "./repo.js";
|
||||||
import { IMonotonicallyIncreasingIdGenerator } from "../application/lib/id-gen.js";
|
import { IMonotonicallyIncreasingIdGenerator } from "../application/lib/id-gen.js";
|
||||||
import { IBus } from "../application/lib/bus.js";
|
import { IBus } from "../application/lib/bus.js";
|
||||||
import { IMessageQueue } from "../application/lib/message-queue.js";
|
import { IMessageQueue, type MiddlePaneContext } from "../application/lib/message-queue.js";
|
||||||
import { IRunsRepo } from "../runs/repo.js";
|
import { IRunsRepo } from "../runs/repo.js";
|
||||||
import { IRunsLock } from "../runs/lock.js";
|
import { IRunsLock } from "../runs/lock.js";
|
||||||
import { IAbortRegistry } from "../runs/abort-registry.js";
|
import { IAbortRegistry } from "../runs/abort-registry.js";
|
||||||
|
|
@ -36,6 +36,7 @@ import { getRaw as getLabelingAgentRaw } from "../knowledge/labeling_agent.js";
|
||||||
import { getRaw as getNoteTaggingAgentRaw } from "../knowledge/note_tagging_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 getInlineTaskAgentRaw } from "../knowledge/inline_task_agent.js";
|
||||||
import { getRaw as getAgentNotesAgentRaw } from "../knowledge/agent_notes_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 AGENT_NOTES_DIR = path.join(WorkDir, 'knowledge', 'Agent Notes');
|
||||||
|
|
||||||
|
|
@ -235,6 +236,96 @@ function loadAgentNotesContext(): string | null {
|
||||||
return `# Agent Memory\n\n${sections.join('\n\n')}`;
|
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 {
|
export interface IAgentRuntime {
|
||||||
trigger(runId: string): Promise<void>;
|
trigger(runId: string): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
@ -392,9 +483,10 @@ export async function mapAgentTool(t: z.infer<typeof ToolAttachment>): Promise<T
|
||||||
case "builtin": {
|
case "builtin": {
|
||||||
if (t.name === "ask-human") {
|
if (t.name === "ask-human") {
|
||||||
return tool({
|
return tool({
|
||||||
description: "Ask a human before proceeding",
|
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.",
|
||||||
inputSchema: z.object({
|
inputSchema: z.object({
|
||||||
question: z.string().describe("The question to ask the human"),
|
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."),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -721,17 +813,18 @@ export function convertFromMessages(messages: z.infer<typeof Message>[]): ModelM
|
||||||
providerOptions,
|
providerOptions,
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
case "user":
|
case "user": {
|
||||||
|
const userMessageContextPrefix = msg.userMessageContext ? formatUserMessageContextForLlm(msg.userMessageContext) : '';
|
||||||
if (typeof msg.content === 'string') {
|
if (typeof msg.content === 'string') {
|
||||||
// Legacy string — pass through unchanged
|
// Legacy string — pass through unchanged
|
||||||
result.push({
|
result.push({
|
||||||
role: "user",
|
role: "user",
|
||||||
content: msg.content,
|
content: `${userMessageContextPrefix}${msg.content}`,
|
||||||
providerOptions,
|
providerOptions,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// New content parts array — collapse to text for LLM
|
// New content parts array — collapse to text for LLM
|
||||||
const textSegments: string[] = [];
|
const textSegments: string[] = userMessageContextPrefix ? [userMessageContextPrefix] : [];
|
||||||
const attachmentLines: string[] = [];
|
const attachmentLines: string[] = [];
|
||||||
|
|
||||||
for (const part of msg.content) {
|
for (const part of msg.content) {
|
||||||
|
|
@ -745,7 +838,11 @@ export function convertFromMessages(messages: z.infer<typeof Message>[]): ModelM
|
||||||
}
|
}
|
||||||
|
|
||||||
if (attachmentLines.length > 0) {
|
if (attachmentLines.length > 0) {
|
||||||
textSegments.unshift("User has attached the following files:", ...attachmentLines, "");
|
if (userMessageContextPrefix) {
|
||||||
|
textSegments.push("User has attached the following files:", ...attachmentLines, "");
|
||||||
|
} else {
|
||||||
|
textSegments.unshift("User has attached the following files:", ...attachmentLines, "");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
result.push({
|
result.push({
|
||||||
|
|
@ -755,6 +852,7 @@ export function convertFromMessages(messages: z.infer<typeof Message>[]): ModelM
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
case "tool":
|
case "tool":
|
||||||
result.push({
|
result.push({
|
||||||
role: "tool",
|
role: "tool",
|
||||||
|
|
@ -804,6 +902,7 @@ export class AgentState {
|
||||||
agentName: string | null = null;
|
agentName: string | null = null;
|
||||||
runModel: string | null = null;
|
runModel: string | null = null;
|
||||||
runProvider: string | null = null;
|
runProvider: string | null = null;
|
||||||
|
permissionMode: "manual" | "auto" = "manual";
|
||||||
runUseCase: UseCase | null = null;
|
runUseCase: UseCase | null = null;
|
||||||
runSubUseCase: string | null = null;
|
runSubUseCase: string | null = null;
|
||||||
messages: z.infer<typeof MessageList> = [];
|
messages: z.infer<typeof MessageList> = [];
|
||||||
|
|
@ -815,6 +914,8 @@ export class AgentState {
|
||||||
pendingAskHumanRequests: Record<string, z.infer<typeof AskHumanRequestEvent>> = {};
|
pendingAskHumanRequests: Record<string, z.infer<typeof AskHumanRequestEvent>> = {};
|
||||||
allowedToolCallIds: Record<string, true> = {};
|
allowedToolCallIds: Record<string, true> = {};
|
||||||
deniedToolCallIds: Record<string, true> = {};
|
deniedToolCallIds: Record<string, true> = {};
|
||||||
|
autoAllowedToolCalls: Record<string, { reason: string }> = {};
|
||||||
|
autoDeniedToolCalls: Record<string, { reason: string }> = {};
|
||||||
sessionAllowedCommands: Set<string> = new Set();
|
sessionAllowedCommands: Set<string> = new Set();
|
||||||
sessionAllowedFileAccess: FileAccessGrant[] = [];
|
sessionAllowedFileAccess: FileAccessGrant[] = [];
|
||||||
|
|
||||||
|
|
@ -922,6 +1023,7 @@ export class AgentState {
|
||||||
this.agentName = event.agentName;
|
this.agentName = event.agentName;
|
||||||
this.runModel = event.model;
|
this.runModel = event.model;
|
||||||
this.runProvider = event.provider;
|
this.runProvider = event.provider;
|
||||||
|
this.permissionMode = event.permissionMode ?? "manual";
|
||||||
this.runUseCase = event.useCase ?? null;
|
this.runUseCase = event.useCase ?? null;
|
||||||
this.runSubUseCase = event.subUseCase ?? null;
|
this.runSubUseCase = event.subUseCase ?? null;
|
||||||
break;
|
break;
|
||||||
|
|
@ -934,6 +1036,7 @@ export class AgentState {
|
||||||
this.subflowStates[event.toolCallId].agentName = event.agentName;
|
this.subflowStates[event.toolCallId].agentName = event.agentName;
|
||||||
this.subflowStates[event.toolCallId].runModel = this.runModel;
|
this.subflowStates[event.toolCallId].runModel = this.runModel;
|
||||||
this.subflowStates[event.toolCallId].runProvider = this.runProvider;
|
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].runUseCase = this.runUseCase;
|
||||||
this.subflowStates[event.toolCallId].runSubUseCase = this.runSubUseCase;
|
this.subflowStates[event.toolCallId].runSubUseCase = this.runSubUseCase;
|
||||||
break;
|
break;
|
||||||
|
|
@ -984,10 +1087,22 @@ export class AgentState {
|
||||||
break;
|
break;
|
||||||
case "deny":
|
case "deny":
|
||||||
this.deniedToolCallIds[event.toolCallId] = true;
|
this.deniedToolCallIds[event.toolCallId] = true;
|
||||||
|
delete this.autoDeniedToolCalls[event.toolCallId];
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
delete this.pendingToolPermissionRequests[event.toolCallId];
|
delete this.pendingToolPermissionRequests[event.toolCallId];
|
||||||
break;
|
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":
|
case "ask-human-request":
|
||||||
this.pendingAskHumanRequests[event.toolCallId] = event;
|
this.pendingAskHumanRequests[event.toolCallId] = event;
|
||||||
break;
|
break;
|
||||||
|
|
@ -1065,6 +1180,7 @@ export async function* streamAgent({
|
||||||
let voiceInput = false;
|
let voiceInput = false;
|
||||||
let voiceOutput: 'summary' | 'full' | null = null;
|
let voiceOutput: 'summary' | 'full' | null = null;
|
||||||
let searchEnabled = false;
|
let searchEnabled = false;
|
||||||
|
let codeMode: 'claude' | 'codex' | null = null;
|
||||||
let middlePaneContext:
|
let middlePaneContext:
|
||||||
| { kind: 'note'; path: string; content: string }
|
| { kind: 'note'; path: string; content: string }
|
||||||
| { kind: 'browser'; url: string; title: string }
|
| { kind: 'browser'; url: string; title: string }
|
||||||
|
|
@ -1092,13 +1208,19 @@ export async function* streamAgent({
|
||||||
// if tool has been denied, deny
|
// if tool has been denied, deny
|
||||||
if (state.deniedToolCallIds[toolCallId]) {
|
if (state.deniedToolCallIds[toolCallId]) {
|
||||||
_logger.log('returning denied tool message, reason: tool has been denied');
|
_logger.log('returning denied tool message, reason: tool has been denied');
|
||||||
|
const autoDenied = state.autoDeniedToolCalls[toolCallId];
|
||||||
yield* processEvent({
|
yield* processEvent({
|
||||||
runId,
|
runId,
|
||||||
messageId: await idGenerator.next(),
|
messageId: await idGenerator.next(),
|
||||||
type: "message",
|
type: "message",
|
||||||
message: {
|
message: {
|
||||||
role: "tool",
|
role: "tool",
|
||||||
content: "Unable to execute this tool: Permission was denied.",
|
content: autoDenied
|
||||||
|
? JSON.stringify({
|
||||||
|
success: false,
|
||||||
|
error: `Auto-permission denied: ${autoDenied.reason}`,
|
||||||
|
})
|
||||||
|
: "Unable to execute this tool: Permission was denied.",
|
||||||
toolCallId: toolCallId,
|
toolCallId: toolCallId,
|
||||||
toolName: toolCall.toolName,
|
toolName: toolCall.toolName,
|
||||||
},
|
},
|
||||||
|
|
@ -1157,6 +1279,7 @@ export async function* streamAgent({
|
||||||
signal,
|
signal,
|
||||||
abortRegistry,
|
abortRegistry,
|
||||||
publish: (event) => bus.publish(event),
|
publish: (event) => bus.publish(event),
|
||||||
|
codeMode,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -1213,6 +1336,9 @@ export async function* streamAgent({
|
||||||
if (msg.searchEnabled) {
|
if (msg.searchEnabled) {
|
||||||
searchEnabled = true;
|
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) {
|
if (msg.voiceOutput) {
|
||||||
voiceOutput = msg.voiceOutput;
|
voiceOutput = msg.voiceOutput;
|
||||||
}
|
}
|
||||||
|
|
@ -1220,6 +1346,10 @@ export async function* streamAgent({
|
||||||
// latest user message. If the user closed the pane between messages, clear it.
|
// latest user message. If the user closed the pane between messages, clear it.
|
||||||
middlePaneContext = msg.middlePaneContext ?? null;
|
middlePaneContext = msg.middlePaneContext ?? null;
|
||||||
loopLogger.log('dequeued user message', msg.messageId);
|
loopLogger.log('dequeued user message', msg.messageId);
|
||||||
|
const userMessageContext = buildUserMessageContext({
|
||||||
|
agentName: state.agentName,
|
||||||
|
middlePaneContext,
|
||||||
|
});
|
||||||
yield* processEvent({
|
yield* processEvent({
|
||||||
runId,
|
runId,
|
||||||
type: "message",
|
type: "message",
|
||||||
|
|
@ -1227,6 +1357,7 @@ export async function* streamAgent({
|
||||||
message: {
|
message: {
|
||||||
role: "user",
|
role: "user",
|
||||||
content: msg.message,
|
content: msg.message,
|
||||||
|
userMessageContext,
|
||||||
},
|
},
|
||||||
subflow: [],
|
subflow: [],
|
||||||
});
|
});
|
||||||
|
|
@ -1248,17 +1379,7 @@ export async function* streamAgent({
|
||||||
loopLogger.log('running llm turn');
|
loopLogger.log('running llm turn');
|
||||||
// stream agent response and build message
|
// stream agent response and build message
|
||||||
const messageBuilder = new StreamStepMessageBuilder();
|
const messageBuilder = new StreamStepMessageBuilder();
|
||||||
const now = new Date();
|
let instructionsWithDateTime = `${agent.instructions}\n\n${USER_CONTEXT_SYSTEM_INSTRUCTIONS}`;
|
||||||
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
|
// Inject Agent Notes context for copilot
|
||||||
if (state.agentName === 'copilot' || state.agentName === 'rowboatx') {
|
if (state.agentName === 'copilot' || state.agentName === 'rowboatx') {
|
||||||
const agentNotesContext = loadAgentNotesContext();
|
const agentNotesContext = loadAgentNotesContext();
|
||||||
|
|
@ -1287,19 +1408,6 @@ Use absolute paths rooted at this directory with the \`file-*\` tools. For examp
|
||||||
|
|
||||||
Do not announce the work directory unless it's relevant. Just use it.`;
|
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) {
|
if (voiceInput) {
|
||||||
loopLogger.log('voice input enabled, injecting voice input prompt');
|
loopLogger.log('voice input enabled, injecting voice input prompt');
|
||||||
|
|
@ -1316,6 +1424,25 @@ Do not announce the work directory unless it's relevant. Just use it.`;
|
||||||
loopLogger.log('search enabled, injecting search prompt');
|
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.`;
|
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;
|
let streamError: string | null = null;
|
||||||
for await (const event of streamLlm(
|
for await (const event of streamLlm(
|
||||||
model,
|
model,
|
||||||
|
|
@ -1366,16 +1493,22 @@ Do not announce the work directory unless it's relevant. Just use it.`;
|
||||||
|
|
||||||
// if there were any ask-human calls, emit those events
|
// if there were any ask-human calls, emit those events
|
||||||
if (message.content instanceof Array) {
|
if (message.content instanceof Array) {
|
||||||
|
const permissionCandidates: AutoPermissionCandidate[] = [];
|
||||||
for (const part of message.content) {
|
for (const part of message.content) {
|
||||||
if (part.type === "tool-call") {
|
if (part.type === "tool-call") {
|
||||||
const underlyingTool = agent.tools![part.toolName];
|
const underlyingTool = agent.tools![part.toolName];
|
||||||
if (underlyingTool.type === "builtin" && underlyingTool.name === "ask-human") {
|
if (underlyingTool.type === "builtin" && underlyingTool.name === "ask-human") {
|
||||||
loopLogger.log('emitting ask-human-request, toolCallId:', part.toolCallId);
|
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({
|
yield* processEvent({
|
||||||
runId,
|
runId,
|
||||||
type: "ask-human-request",
|
type: "ask-human-request",
|
||||||
toolCallId: part.toolCallId,
|
toolCallId: part.toolCallId,
|
||||||
query: part.arguments.question,
|
query: part.arguments.question,
|
||||||
|
...(options && options.length > 0 ? { options } : {}),
|
||||||
subflow: [],
|
subflow: [],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -1386,14 +1519,7 @@ Do not announce the work directory unless it's relevant. Just use it.`;
|
||||||
state.sessionAllowedFileAccess,
|
state.sessionAllowedFileAccess,
|
||||||
);
|
);
|
||||||
if (permission) {
|
if (permission) {
|
||||||
loopLogger.log('emitting tool-permission-request, toolCallId:', part.toolCallId);
|
permissionCandidates.push({ toolCall: part, permission });
|
||||||
yield* processEvent({
|
|
||||||
runId,
|
|
||||||
type: "tool-permission-request",
|
|
||||||
toolCall: part,
|
|
||||||
permission,
|
|
||||||
subflow: [],
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
if (underlyingTool.type === "agent" && underlyingTool.name) {
|
if (underlyingTool.type === "agent" && underlyingTool.name) {
|
||||||
loopLogger.log('emitting spawn-subflow, toolCallId:', part.toolCallId);
|
loopLogger.log('emitting spawn-subflow, toolCallId:', part.toolCallId);
|
||||||
|
|
@ -1417,6 +1543,87 @@ Do not announce the work directory unless it's relevant. Just use it.`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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,6 +6,7 @@ import { API_URL } from '../config/env.js';
|
||||||
// In dev/tsc, fall back to process.env so local runs work too.
|
// 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_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 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 client: PostHog | null = null;
|
||||||
let initAttempted = false;
|
let initAttempted = false;
|
||||||
|
|
@ -29,7 +30,7 @@ function getClient(): PostHog | null {
|
||||||
// distinguishes prod / staging / custom — meaning is assigned in PostHog).
|
// distinguishes prod / staging / custom — meaning is assigned in PostHog).
|
||||||
client.identify({
|
client.identify({
|
||||||
distinctId: getInstallationId(),
|
distinctId: getInstallationId(),
|
||||||
properties: { api_url: API_URL },
|
properties: { api_url: API_URL, ...appVersionProperties() },
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[Analytics] Failed to init PostHog:', err);
|
console.error('[Analytics] Failed to init PostHog:', err);
|
||||||
|
|
@ -42,6 +43,10 @@ function activeDistinctId(): string {
|
||||||
return identifiedUserId ?? getInstallationId();
|
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 {
|
export function capture(event: string, properties?: Record<string, unknown>): void {
|
||||||
const ph = getClient();
|
const ph = getClient();
|
||||||
if (!ph) return;
|
if (!ph) return;
|
||||||
|
|
@ -49,7 +54,10 @@ export function capture(event: string, properties?: Record<string, unknown>): vo
|
||||||
ph.capture({
|
ph.capture({
|
||||||
distinctId: activeDistinctId(),
|
distinctId: activeDistinctId(),
|
||||||
event,
|
event,
|
||||||
properties,
|
properties: {
|
||||||
|
...properties,
|
||||||
|
...appVersionProperties(),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[Analytics] capture failed:', err);
|
console.error('[Analytics] capture failed:', err);
|
||||||
|
|
@ -68,6 +76,7 @@ export function identify(userId: string, properties?: Record<string, unknown>):
|
||||||
properties: {
|
properties: {
|
||||||
...properties,
|
...properties,
|
||||||
api_url: API_URL,
|
api_url: API_URL,
|
||||||
|
...appVersionProperties(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
identifiedUserId = userId;
|
identifiedUserId = userId;
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@ import { getRuntimeContext, getRuntimeContextPrompt } from "./runtime-context.js
|
||||||
import { composioAccountsRepo } from "../../composio/repo.js";
|
import { composioAccountsRepo } from "../../composio/repo.js";
|
||||||
import { isConfigured as isComposioConfigured } from "../../composio/client.js";
|
import { isConfigured as isComposioConfigured } from "../../composio/client.js";
|
||||||
import { CURATED_TOOLKITS } from "@x/shared/dist/composio.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());
|
const runtimeContextPrompt = getRuntimeContextPrompt(getRuntimeContext());
|
||||||
|
|
||||||
|
|
@ -29,7 +31,7 @@ Load the \`composio-integration\` skill when the user asks to interact with any
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildStaticInstructions(composioEnabled: boolean, catalog: string): string {
|
function buildStaticInstructions(composioEnabled: boolean, catalog: string, codeModeEnabled: boolean = true): string {
|
||||||
// Conditionally include Composio-related instruction sections
|
// Conditionally include Composio-related instruction sections
|
||||||
const emailDraftSuffix = composioEnabled
|
const emailDraftSuffix = composioEnabled
|
||||||
? ` Do NOT load this skill for reading, fetching, or checking emails — use the \`composio-integration\` skill for that instead.`
|
? ` Do NOT load this skill for reading, fetching, or checking emails — use the \`composio-integration\` skill for that instead.`
|
||||||
|
|
@ -80,7 +82,9 @@ ${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.
|
**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.
|
||||||
|
|
||||||
**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.
|
${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.`}
|
||||||
|
|
||||||
**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.
|
**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.
|
||||||
|
|
||||||
|
|
@ -312,30 +316,29 @@ Never output raw file paths in plain text when they could be wrapped in a filepa
|
||||||
/** Keep backward-compatible export for any external consumers */
|
/** Keep backward-compatible export for any external consumers */
|
||||||
export const CopilotInstructions = buildStaticInstructions(true, skillCatalog);
|
export const CopilotInstructions = buildStaticInstructions(true, skillCatalog);
|
||||||
|
|
||||||
/**
|
|
||||||
* Cached Composio instructions. Invalidated by calling invalidateCopilotInstructionsCache().
|
|
||||||
*/
|
|
||||||
let cachedInstructions: string | null = null;
|
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 {
|
export function invalidateCopilotInstructionsCache(): void {
|
||||||
cachedInstructions = null;
|
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> {
|
export async function buildCopilotInstructions(): Promise<string> {
|
||||||
if (cachedInstructions !== null) return cachedInstructions;
|
if (cachedInstructions !== null) return cachedInstructions;
|
||||||
const composioEnabled = await isComposioConfigured();
|
const composioEnabled = await isComposioConfigured();
|
||||||
const catalog = composioEnabled
|
let codeModeEnabled = false;
|
||||||
? skillCatalog
|
try {
|
||||||
: buildSkillCatalog({ excludeIds: ['composio-integration'] });
|
const repo = container.resolve<ICodeModeConfigRepo>('codeModeConfigRepo');
|
||||||
const baseInstructions = buildStaticInstructions(composioEnabled, catalog);
|
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 composioPrompt = await getComposioToolsPrompt();
|
const composioPrompt = await getComposioToolsPrompt();
|
||||||
cachedInstructions = composioPrompt
|
cachedInstructions = composioPrompt
|
||||||
? baseInstructions + '\n' + composioPrompt
|
? baseInstructions + '\n' + composioPrompt
|
||||||
|
|
|
||||||
|
|
@ -1,90 +1,98 @@
|
||||||
export const skill = String.raw`
|
export const skill = String.raw`
|
||||||
# Code with Agents Skill
|
# Code with Agents Skill
|
||||||
|
|
||||||
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).
|
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".
|
||||||
|
|
||||||
## Important: delegate ALL coding work
|
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.
|
||||||
|
|
||||||
Once the user has chosen to use Claude Code or Codex, you MUST delegate ALL code-related tasks to the coding agent. This includes:
|
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.
|
||||||
- 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.
|
---
|
||||||
|
|
||||||
## Prerequisites
|
## STEP 1 — MANDATORY FIRST ACTION
|
||||||
|
|
||||||
The user must have one of the following installed on their machine:
|
Look in your **system context** for a section titled **"# Code Mode (Active)"**.
|
||||||
- **Claude Code** — https://claude.ai/code
|
|
||||||
- **Codex** — https://codex.openai.com
|
|
||||||
|
|
||||||
These are external tools that you cannot install for the user.
|
### Case A — "# Code Mode (Active)" IS present
|
||||||
|
|
||||||
## Workflow
|
Code mode is on and the user has selected an agent. Skip directly to Step 2. Do NOT call ask-human.
|
||||||
|
|
||||||
### Step 1: Gather requirements
|
### Case B — "# Code Mode (Active)" is NOT present
|
||||||
|
|
||||||
Before running anything, confirm the following with the user:
|
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:
|
||||||
|
|
||||||
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.
|
ask-human({
|
||||||
|
question: "How should I handle this coding request?",
|
||||||
|
options: [
|
||||||
|
"Use code mode (Claude Code)",
|
||||||
|
"Use code mode (Codex)",
|
||||||
|
"Continue with default Rowboat"
|
||||||
|
]
|
||||||
|
})
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
### Step 2: Confirm execution plan
|
This is non-negotiable. The user gets clickable buttons. Free-text "which agent?" questions are forbidden here.
|
||||||
|
|
||||||
Once you know the folder and agent, tell the user:
|
**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.
|
||||||
|
|
||||||
> 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 3: Execute with acpx
|
## STEP 2 — Resolve workdir, then run
|
||||||
|
|
||||||
Use the \`executeCommand\` tool to run the coding agent via acpx. The command format is:
|
**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?"
|
||||||
|
|
||||||
**For Claude Code:**
|
**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.
|
||||||
` + "`" + `
|
|
||||||
npx acpx@latest --approve-all --cwd <folder> claude exec "<prompt>"
|
|
||||||
` + "`" + `
|
|
||||||
|
|
||||||
**For Codex:**
|
**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:
|
||||||
` + "`" + `
|
|
||||||
npx acpx@latest --approve-all --cwd <folder> codex exec "<prompt>"
|
|
||||||
` + "`" + `
|
|
||||||
|
|
||||||
### Critical: flag order
|
> Using [Claude Code / Codex] to [task description] in \`[folder]\`.
|
||||||
|
|
||||||
The \`--approve-all\` and \`--cwd\` flags are global flags and MUST come before the agent name (\`claude\` or \`codex\`). This is the correct order:
|
…and then immediately call:
|
||||||
|
|
||||||
` + "`" + `
|
\`\`\`
|
||||||
npx acpx@latest [global flags] <agent> exec "<prompt>"
|
code_agent_run({
|
||||||
` + "`" + `
|
agent: "<claude|codex>",
|
||||||
|
cwd: "<resolved absolute folder>",
|
||||||
|
prompt: "<clear, self-contained coding instruction>"
|
||||||
|
})
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
**Correct:**
|
**Writing good prompts for the agent:**
|
||||||
` + "`" + `
|
- Be specific: file names, function signatures, expected behavior.
|
||||||
npx acpx@latest --approve-all --cwd ~/projects/myapp claude exec "fix the bug"
|
- Mention constraints (language, framework, style).
|
||||||
` + "`" + `
|
- Expand short user requests into clear, actionable instructions.
|
||||||
|
|
||||||
**Wrong (will fail):**
|
**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.
|
||||||
` + "`" + `
|
|
||||||
npx acpx@latest claude --approve-all exec "fix the bug"
|
|
||||||
` + "`" + `
|
|
||||||
|
|
||||||
### Writing good prompts
|
---
|
||||||
|
|
||||||
When constructing the prompt for the coding agent:
|
## STEP 3 — Report results
|
||||||
- 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
|
|
||||||
|
|
||||||
### Step 4: Report results
|
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.
|
||||||
|
|
||||||
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.
|
---
|
||||||
|
|
||||||
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.
|
## Once delegating: delegate fully
|
||||||
|
|
||||||
- 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
|
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
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export default skill;
|
export default skill;
|
||||||
|
|
|
||||||
|
|
@ -99,7 +99,7 @@ const definitions: SkillDefinition[] = [
|
||||||
{
|
{
|
||||||
id: "code-with-agents",
|
id: "code-with-agents",
|
||||||
title: "Code with Agents",
|
title: "Code with Agents",
|
||||||
summary: "Write code, build projects, create scripts, or fix bugs by delegating to Claude Code or Codex via acpx.",
|
summary: "Write code, build projects, create scripts, or fix bugs by delegating to Claude Code or Codex.",
|
||||||
content: codeWithAgentsSkill,
|
content: codeWithAgentsSkill,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import { z, ZodType } from "zod";
|
import { z, ZodType } from "zod";
|
||||||
import * as path from "path";
|
import * as path from "path";
|
||||||
import * as fs from "fs/promises";
|
import * as fs from "fs/promises";
|
||||||
import { existsSync, readFileSync } from "fs";
|
|
||||||
import { executeCommand, executeCommandAbortable } from "./command-executor.js";
|
import { executeCommand, executeCommandAbortable } from "./command-executor.js";
|
||||||
import { resolveSkill, availableSkills } from "../assistant/skills/index.js";
|
import { resolveSkill, availableSkills } from "../assistant/skills/index.js";
|
||||||
import { executeTool, listServers, listTools } from "../../mcp/mcp.js";
|
import { executeTool, listServers, listTools } from "../../mcp/mcp.js";
|
||||||
|
|
@ -16,6 +15,10 @@ import { executeAction as executeComposioAction, isConfigured as isComposioConfi
|
||||||
import { CURATED_TOOLKITS, CURATED_TOOLKIT_SLUGS } from "@x/shared/dist/composio.js";
|
import { CURATED_TOOLKITS, CURATED_TOOLKIT_SLUGS } from "@x/shared/dist/composio.js";
|
||||||
import { BrowserControlInputSchema, type BrowserControlInput } from "@x/shared/dist/browser-control.js";
|
import { BrowserControlInputSchema, type BrowserControlInput } from "@x/shared/dist/browser-control.js";
|
||||||
import { BackgroundTaskSchema, TriggersSchema } from "@x/shared/dist/background-task.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
|
// Inputs for the bg-task builtin tools. Reuse the canonical schema field
|
||||||
// descriptions; only `triggers` gets a tighter contextual override (the
|
// descriptions; only `triggers` gets a tighter contextual override (the
|
||||||
|
|
@ -90,43 +93,6 @@ const LLMPARSE_MIME_TYPES: Record<string, string> = {
|
||||||
'.tiff': 'image/tiff',
|
'.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> = {
|
export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
||||||
loadSkill: {
|
loadSkill: {
|
||||||
|
|
@ -788,14 +754,11 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
||||||
// };
|
// };
|
||||||
// }
|
// }
|
||||||
|
|
||||||
const envOverride = envForCommand(command);
|
|
||||||
|
|
||||||
// Use abortable version when we have a signal
|
// Use abortable version when we have a signal
|
||||||
if (ctx?.signal) {
|
if (ctx?.signal) {
|
||||||
const { promise, process: proc } = executeCommandAbortable(command, {
|
const { promise, process: proc } = executeCommandAbortable(command, {
|
||||||
cwd: workingDir,
|
cwd: workingDir,
|
||||||
signal: ctx.signal,
|
signal: ctx.signal,
|
||||||
env: envOverride,
|
|
||||||
onData: (chunk: string) => {
|
onData: (chunk: string) => {
|
||||||
ctx.publish({
|
ctx.publish({
|
||||||
runId: ctx.runId,
|
runId: ctx.runId,
|
||||||
|
|
@ -845,6 +808,104 @@ 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)
|
// Browser Skills (browser-use/browser-harness domain-skills cache)
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,7 @@ export async function executeCommand(
|
||||||
cwd?: string;
|
cwd?: string;
|
||||||
timeout?: number; // timeout in milliseconds
|
timeout?: number; // timeout in milliseconds
|
||||||
maxBuffer?: number; // max buffer size in bytes
|
maxBuffer?: number; // max buffer size in bytes
|
||||||
|
env?: NodeJS.ProcessEnv; // override environment
|
||||||
}
|
}
|
||||||
): Promise<CommandResult> {
|
): Promise<CommandResult> {
|
||||||
try {
|
try {
|
||||||
|
|
@ -89,6 +90,7 @@ export async function executeCommand(
|
||||||
timeout: options?.timeout,
|
timeout: options?.timeout,
|
||||||
maxBuffer: options?.maxBuffer || 1024 * 1024, // default 1MB
|
maxBuffer: options?.maxBuffer || 1024 * 1024, // default 1MB
|
||||||
shell,
|
shell,
|
||||||
|
env: options?.env,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,10 @@ export interface ToolContext {
|
||||||
signal: AbortSignal;
|
signal: AbortSignal;
|
||||||
abortRegistry: IAbortRegistry;
|
abortRegistry: IAbortRegistry;
|
||||||
publish: (event: z.infer<typeof RunEvent>) => Promise<void>;
|
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> {
|
async function execMcpTool(agentTool: z.infer<typeof ToolAttachment> & { type: "mcp" }, input: Record<string, unknown>): Promise<unknown> {
|
||||||
|
|
|
||||||
|
|
@ -8,17 +8,20 @@ export type MiddlePaneContext =
|
||||||
| { kind: 'note'; path: string; content: string }
|
| { kind: 'note'; path: string; content: string }
|
||||||
| { kind: 'browser'; url: string; title: string };
|
| { kind: 'browser'; url: string; title: string };
|
||||||
|
|
||||||
|
export type CodeMode = 'claude' | 'codex';
|
||||||
|
|
||||||
type EnqueuedMessage = {
|
type EnqueuedMessage = {
|
||||||
messageId: string;
|
messageId: string;
|
||||||
message: UserMessageContentType;
|
message: UserMessageContentType;
|
||||||
voiceInput?: boolean;
|
voiceInput?: boolean;
|
||||||
voiceOutput?: VoiceOutputMode;
|
voiceOutput?: VoiceOutputMode;
|
||||||
searchEnabled?: boolean;
|
searchEnabled?: boolean;
|
||||||
|
codeMode?: CodeMode;
|
||||||
middlePaneContext?: MiddlePaneContext;
|
middlePaneContext?: MiddlePaneContext;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface IMessageQueue {
|
export interface IMessageQueue {
|
||||||
enqueue(runId: string, message: UserMessageContentType, voiceInput?: boolean, voiceOutput?: VoiceOutputMode, searchEnabled?: boolean, middlePaneContext?: MiddlePaneContext): Promise<string>;
|
enqueue(runId: string, message: UserMessageContentType, voiceInput?: boolean, voiceOutput?: VoiceOutputMode, searchEnabled?: boolean, middlePaneContext?: MiddlePaneContext, codeMode?: CodeMode): Promise<string>;
|
||||||
dequeue(runId: string): Promise<EnqueuedMessage | null>;
|
dequeue(runId: string): Promise<EnqueuedMessage | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -34,7 +37,7 @@ export class InMemoryMessageQueue implements IMessageQueue {
|
||||||
this.idGenerator = idGenerator;
|
this.idGenerator = idGenerator;
|
||||||
}
|
}
|
||||||
|
|
||||||
async enqueue(runId: string, message: UserMessageContentType, voiceInput?: boolean, voiceOutput?: VoiceOutputMode, searchEnabled?: boolean, middlePaneContext?: MiddlePaneContext): Promise<string> {
|
async enqueue(runId: string, message: UserMessageContentType, voiceInput?: boolean, voiceOutput?: VoiceOutputMode, searchEnabled?: boolean, middlePaneContext?: MiddlePaneContext, codeMode?: CodeMode): Promise<string> {
|
||||||
if (!this.store[runId]) {
|
if (!this.store[runId]) {
|
||||||
this.store[runId] = [];
|
this.store[runId] = [];
|
||||||
}
|
}
|
||||||
|
|
@ -45,6 +48,7 @@ export class InMemoryMessageQueue implements IMessageQueue {
|
||||||
voiceInput,
|
voiceInput,
|
||||||
voiceOutput,
|
voiceOutput,
|
||||||
searchEnabled,
|
searchEnabled,
|
||||||
|
codeMode,
|
||||||
middlePaneContext,
|
middlePaneContext,
|
||||||
});
|
});
|
||||||
return id;
|
return id;
|
||||||
|
|
|
||||||
|
|
@ -71,7 +71,9 @@ The workspace lives at \`${WorkDir}\`.
|
||||||
export function buildBackgroundTaskAgent(): z.infer<typeof Agent> {
|
export function buildBackgroundTaskAgent(): z.infer<typeof Agent> {
|
||||||
const tools: Record<string, z.infer<typeof ToolAttachment>> = {};
|
const tools: Record<string, z.infer<typeof ToolAttachment>> = {};
|
||||||
for (const name of Object.keys(BuiltinTools)) {
|
for (const name of Object.keys(BuiltinTools)) {
|
||||||
if (name === 'executeCommand') continue;
|
// 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;
|
||||||
tools[name] = { type: 'builtin', name };
|
tools[name] = { type: 'builtin', name };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
60
apps/x/packages/core/src/code-mode/acp/agents.ts
Normal file
60
apps/x/packages/core/src/code-mode/acp/agents.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
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 };
|
||||||
|
}
|
||||||
91
apps/x/packages/core/src/code-mode/acp/claude-exec.ts
Normal file
91
apps/x/packages/core/src/code-mode/acp/claude-exec.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
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;
|
||||||
|
}
|
||||||
219
apps/x/packages/core/src/code-mode/acp/client.ts
Normal file
219
apps/x/packages/core/src/code-mode/acp/client.ts
Normal file
|
|
@ -0,0 +1,219 @@
|
||||||
|
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 {};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
186
apps/x/packages/core/src/code-mode/acp/manager.ts
Normal file
186
apps/x/packages/core/src/code-mode/acp/manager.ts
Normal file
|
|
@ -0,0 +1,186 @@
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
91
apps/x/packages/core/src/code-mode/acp/permission-broker.ts
Normal file
91
apps/x/packages/core/src/code-mode/acp/permission-broker.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
48
apps/x/packages/core/src/code-mode/acp/session-store.ts
Normal file
48
apps/x/packages/core/src/code-mode/acp/session-store.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
11
apps/x/packages/core/src/code-mode/acp/types.ts
Normal file
11
apps/x/packages/core/src/code-mode/acp/types.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
// 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';
|
||||||
3
apps/x/packages/core/src/code-mode/index.ts
Normal file
3
apps/x/packages/core/src/code-mode/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
export { CodeModeConfig, CodeModeAgentStatus, AgentStatus } from './types.js';
|
||||||
|
export { FSCodeModeConfigRepo, type ICodeModeConfigRepo } from './repo.js';
|
||||||
|
export { checkCodeModeAgentStatus } from './status.js';
|
||||||
47
apps/x/packages/core/src/code-mode/repo.ts
Normal file
47
apps/x/packages/core/src/code-mode/repo.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
199
apps/x/packages/core/src/code-mode/status.ts
Normal file
199
apps/x/packages/core/src/code-mode/status.ts
Normal file
|
|
@ -0,0 +1,199 @@
|
||||||
|
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 },
|
||||||
|
};
|
||||||
|
}
|
||||||
22
apps/x/packages/core/src/code-mode/types.ts
Normal file
22
apps/x/packages/core/src/code-mode/types.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
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>;
|
||||||
|
|
@ -11,10 +11,13 @@ import { IAgentRuntime, AgentRuntime } from "../agents/runtime.js";
|
||||||
import { FSOAuthRepo, IOAuthRepo } from "../auth/repo.js";
|
import { FSOAuthRepo, IOAuthRepo } from "../auth/repo.js";
|
||||||
import { FSClientRegistrationRepo, IClientRegistrationRepo } from "../auth/client-repo.js";
|
import { FSClientRegistrationRepo, IClientRegistrationRepo } from "../auth/client-repo.js";
|
||||||
import { FSGranolaConfigRepo, IGranolaConfigRepo } from "../knowledge/granola/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 { IAbortRegistry, InMemoryAbortRegistry } from "../runs/abort-registry.js";
|
||||||
import { FSAgentScheduleRepo, IAgentScheduleRepo } from "../agent-schedule/repo.js";
|
import { FSAgentScheduleRepo, IAgentScheduleRepo } from "../agent-schedule/repo.js";
|
||||||
import { FSAgentScheduleStateRepo, IAgentScheduleStateRepo } from "../agent-schedule/state-repo.js";
|
import { FSAgentScheduleStateRepo, IAgentScheduleStateRepo } from "../agent-schedule/state-repo.js";
|
||||||
import { FSSlackConfigRepo, ISlackConfigRepo } from "../slack/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 { IBrowserControlService } from "../application/browser-control/service.js";
|
||||||
import type { INotificationService } from "../application/notification/service.js";
|
import type { INotificationService } from "../application/notification/service.js";
|
||||||
|
|
||||||
|
|
@ -38,9 +41,16 @@ container.register({
|
||||||
oauthRepo: asClass<IOAuthRepo>(FSOAuthRepo).singleton(),
|
oauthRepo: asClass<IOAuthRepo>(FSOAuthRepo).singleton(),
|
||||||
clientRegistrationRepo: asClass<IClientRegistrationRepo>(FSClientRegistrationRepo).singleton(),
|
clientRegistrationRepo: asClass<IClientRegistrationRepo>(FSClientRegistrationRepo).singleton(),
|
||||||
granolaConfigRepo: asClass<IGranolaConfigRepo>(FSGranolaConfigRepo).singleton(),
|
granolaConfigRepo: asClass<IGranolaConfigRepo>(FSGranolaConfigRepo).singleton(),
|
||||||
|
codeModeConfigRepo: asClass<ICodeModeConfigRepo>(FSCodeModeConfigRepo).singleton(),
|
||||||
agentScheduleRepo: asClass<IAgentScheduleRepo>(FSAgentScheduleRepo).singleton(),
|
agentScheduleRepo: asClass<IAgentScheduleRepo>(FSAgentScheduleRepo).singleton(),
|
||||||
agentScheduleStateRepo: asClass<IAgentScheduleStateRepo>(FSAgentScheduleStateRepo).singleton(),
|
agentScheduleStateRepo: asClass<IAgentScheduleStateRepo>(FSAgentScheduleStateRepo).singleton(),
|
||||||
slackConfigRepo: asClass<ISlackConfigRepo>(FSSlackConfigRepo).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;
|
export default container;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
import { BuiltinTools } from '../application/lib/builtin-tools.js';
|
import { BuiltinTools } from '../application/lib/builtin-tools.js';
|
||||||
|
|
||||||
export function getRaw(): string {
|
export function getRaw(): string {
|
||||||
|
// code_agent_run needs an interactive UI to answer its permission asks; exclude it
|
||||||
|
// from this headless agent so it can't hang waiting on an approval no one can give.
|
||||||
const toolEntries = Object.keys(BuiltinTools)
|
const toolEntries = Object.keys(BuiltinTools)
|
||||||
|
.filter(name => name !== 'code_agent_run')
|
||||||
.map(name => ` ${name}:\n type: builtin\n name: ${name}`)
|
.map(name => ` ${name}:\n type: builtin\n name: ${name}`)
|
||||||
.join('\n');
|
.join('\n');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -152,7 +152,9 @@ Avoid: "I updated the note.", "Done!", "Here is the update:". The summary is a d
|
||||||
export function buildLiveNoteAgent(): z.infer<typeof Agent> {
|
export function buildLiveNoteAgent(): z.infer<typeof Agent> {
|
||||||
const tools: Record<string, z.infer<typeof ToolAttachment>> = {};
|
const tools: Record<string, z.infer<typeof ToolAttachment>> = {};
|
||||||
for (const name of Object.keys(BuiltinTools)) {
|
for (const name of Object.keys(BuiltinTools)) {
|
||||||
if (name === 'executeCommand') continue;
|
// 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;
|
||||||
tools[name] = { type: 'builtin', name };
|
tools[name] = { type: 'builtin', name };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
42
apps/x/packages/core/src/knowledge/sync_gmail.test.ts
Normal file
42
apps/x/packages/core/src/knowledge/sync_gmail.test.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
sanitizeReplyBodyForGmailReply,
|
||||||
|
stripGmailQuotedReplyHtml,
|
||||||
|
stripGmailQuotedReplyText,
|
||||||
|
} from './sync_gmail.js';
|
||||||
|
|
||||||
|
describe('Gmail reply body sanitization', () => {
|
||||||
|
it('strips Gmail quote attribution and older quoted text from plain text replies', () => {
|
||||||
|
const body = [
|
||||||
|
'Sounds good, thanks. I will send it over today.',
|
||||||
|
'',
|
||||||
|
'On Thu, 28 May 2026 at 23:45, PRAKHAR <prakhar9999pandey@gmail.com> wrote:',
|
||||||
|
'> Can you share the final file?',
|
||||||
|
'> Thanks',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
expect(stripGmailQuotedReplyText(body)).toBe('Sounds good, thanks. I will send it over today.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips Gmail quote blocks from html replies', () => {
|
||||||
|
const html = [
|
||||||
|
'<p>Sounds good, thanks.</p>',
|
||||||
|
'<div class="gmail_quote">',
|
||||||
|
'<div dir="ltr" class="gmail_attr">On Thu, 28 May 2026 at 23:45, PRAKHAR wrote:<br></div>',
|
||||||
|
'<blockquote>Older thread text</blockquote>',
|
||||||
|
'</div>',
|
||||||
|
].join('');
|
||||||
|
|
||||||
|
expect(stripGmailQuotedReplyHtml(html)).toBe('<p>Sounds good, thanks.</p>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('regenerates html from clean text if only the text boundary is detected', () => {
|
||||||
|
const result = sanitizeReplyBodyForGmailReply(
|
||||||
|
'<p>Sounds good, thanks.</p><p>Older thread text</p>',
|
||||||
|
'Sounds good, thanks.\n\nOn Thu, 28 May 2026 at 23:45, PRAKHAR <prakhar9999pandey@gmail.com> wrote:\nOlder thread text',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.bodyText).toBe('Sounds good, thanks.');
|
||||||
|
expect(result.bodyHtml).toBe('<p>Sounds good, thanks.</p>');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -35,7 +35,7 @@ const nhm = new NodeHtmlMarkdown();
|
||||||
// previously cached snapshots (e.g. attachment / recipient parsing fixes). The
|
// previously cached snapshots (e.g. attachment / recipient parsing fixes). The
|
||||||
// short-circuit in buildAndCacheSnapshot only reuses a cache whose version matches,
|
// short-circuit in buildAndCacheSnapshot only reuses a cache whose version matches,
|
||||||
// so stale entries are transparently rebuilt on the next sync.
|
// so stale entries are transparently rebuilt on the next sync.
|
||||||
const SNAPSHOT_PARSER_VERSION = 2;
|
const SNAPSHOT_PARSER_VERSION = 3;
|
||||||
|
|
||||||
interface SnapshotCacheEntry {
|
interface SnapshotCacheEntry {
|
||||||
historyId: string;
|
historyId: string;
|
||||||
|
|
@ -405,6 +405,112 @@ function normalizeBody(body: string): string {
|
||||||
return body.replace(/\r\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim();
|
return body.replace(/\r\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isGmailQuoteAttribution(line: string): boolean {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
return /^On\b.+\bwrote:\s*$/i.test(trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOriginalMessageBoundary(line: string): boolean {
|
||||||
|
return /^-{2,}\s*Original Message\s*-{2,}$/i.test(line.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function isForwardedMessageBoundary(line: string): boolean {
|
||||||
|
return /^-{2,}\s*Forwarded message\s*-{2,}$/i.test(line.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOutlookHeaderBoundary(lines: string[], index: number): boolean {
|
||||||
|
if (!/^From:\s+\S/i.test(lines[index]?.trim() || '')) return false;
|
||||||
|
const next = lines.slice(index + 1, index + 6).map((line) => line.trim());
|
||||||
|
return next.some((line) => /^(Sent|Date):\s+\S/i.test(line))
|
||||||
|
&& next.some((line) => /^To:\s+\S/i.test(line))
|
||||||
|
&& next.some((line) => /^Subject:\s+\S/i.test(line));
|
||||||
|
}
|
||||||
|
|
||||||
|
function findQuotedReplyBoundary(lines: string[]): number {
|
||||||
|
for (let i = 0; i < lines.length; i += 1) {
|
||||||
|
const line = lines[i] || '';
|
||||||
|
if (
|
||||||
|
isGmailQuoteAttribution(line)
|
||||||
|
|| isOriginalMessageBoundary(line)
|
||||||
|
|| isForwardedMessageBoundary(line)
|
||||||
|
|| isOutlookHeaderBoundary(lines, i)
|
||||||
|
) {
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gmail plain text drafts often carry older messages as a quoted block.
|
||||||
|
// Treat a trailing blockquote as history, but avoid stripping an inline
|
||||||
|
// quote the user is actively writing at the top of the reply.
|
||||||
|
if (i > 0 && line.trim().startsWith('>') && (lines[i - 1]?.trim() === '' || lines[i - 1]?.trim().startsWith('>'))) {
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stripGmailQuotedReplyText(text: string): string {
|
||||||
|
const normalized = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||||||
|
const lines = normalized.split('\n');
|
||||||
|
const boundary = findQuotedReplyBoundary(lines);
|
||||||
|
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 htmlQuoteBoundaryIndex(html: string): number {
|
||||||
|
const candidates: number[] = [];
|
||||||
|
const patterns = [
|
||||||
|
/<[^>]+\bclass\s*=\s*["'][^"']*\bgmail_(?:quote|attr)\b[^"']*["'][^>]*>/i,
|
||||||
|
/<blockquote\b[^>]*(?:type\s*=\s*["']cite["']|class\s*=\s*["'][^"']*\bgmail_quote\b[^"']*["'])[^>]*>/i,
|
||||||
|
/<(p|div|li)\b[^>]*>\s*(?:<(?:span|b|strong|i|em)\b[^>]*>\s*)*On\b[\s\S]{0,800}?\bwrote:\s*(?:<br\s*\/?>\s*)?(?:<\/(?:span|b|strong|i|em)>\s*)*<\/\1>/i,
|
||||||
|
/<(p|div|li)\b[^>]*>\s*-{2,}\s*(?:Original Message|Forwarded message)\s*-{2,}\s*<\/\1>/i,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const pattern of patterns) {
|
||||||
|
const match = pattern.exec(html);
|
||||||
|
if (match?.index !== undefined) candidates.push(match.index);
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidates.length > 0 ? Math.min(...candidates) : -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stripGmailQuotedReplyHtml(html: string): string {
|
||||||
|
const boundary = htmlQuoteBoundaryIndex(html);
|
||||||
|
const visible = boundary >= 0 ? html.slice(0, boundary) : html;
|
||||||
|
return visible.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function textToHtml(text: string): string {
|
||||||
|
return text
|
||||||
|
.split(/\n{2,}/)
|
||||||
|
.map((para) => `<p>${escapeHtml(para).replace(/\n/g, '<br />')}</p>`)
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value: string): string {
|
||||||
|
return value
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sanitizeReplyBodyForGmailReply(bodyHtml: string, bodyText: string): { bodyHtml: string; bodyText: string } {
|
||||||
|
const cleanText = stripGmailQuotedReplyText(bodyText);
|
||||||
|
const cleanHtml = stripGmailQuotedReplyHtml(bodyHtml);
|
||||||
|
const textWasStripped = cleanText !== bodyText.replace(/\r\n/g, '\n').replace(/\r/g, '\n').trim();
|
||||||
|
const htmlWasStripped = cleanHtml !== bodyHtml.trim();
|
||||||
|
|
||||||
|
return {
|
||||||
|
bodyText: cleanText,
|
||||||
|
bodyHtml: textWasStripped && !htmlWasStripped ? textToHtml(cleanText) : cleanHtml,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function headerValue(headers: gmail.Schema$MessagePartHeader[] | undefined, name: string): string | undefined {
|
function headerValue(headers: gmail.Schema$MessagePartHeader[] | undefined, name: string): string | undefined {
|
||||||
return headers?.find(h => h.name?.toLowerCase() === name.toLowerCase())?.value || undefined;
|
return headers?.find(h => h.name?.toLowerCase() === name.toLowerCase())?.value || undefined;
|
||||||
}
|
}
|
||||||
|
|
@ -636,9 +742,13 @@ async function buildAndCacheSnapshot(
|
||||||
|
|
||||||
const sentMessages = parsed.filter((m) => !m.isDraft);
|
const sentMessages = parsed.filter((m) => !m.isDraft);
|
||||||
const draftMessages = parsed.filter((m) => m.isDraft);
|
const draftMessages = parsed.filter((m) => m.isDraft);
|
||||||
const visibleMessages = sentMessages.map(({ isDraft: _isDraft, ...rest }) => rest);
|
const visibleMessages = sentMessages.map((msg) => {
|
||||||
|
const rest: Partial<typeof msg> = { ...msg };
|
||||||
|
delete rest.isDraft;
|
||||||
|
return rest as Omit<typeof msg, 'isDraft'>;
|
||||||
|
});
|
||||||
const latestDraftBody = draftMessages.length > 0
|
const latestDraftBody = draftMessages.length > 0
|
||||||
? draftMessages[draftMessages.length - 1]!.body.trim()
|
? stripGmailQuotedReplyText(draftMessages[draftMessages.length - 1]!.body)
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
if (visibleMessages.length === 0) return null;
|
if (visibleMessages.length === 0) return null;
|
||||||
|
|
@ -674,7 +784,10 @@ async function buildAndCacheSnapshot(
|
||||||
const classification = await classifyThread(snapshot, userEmail, { skipDraft });
|
const classification = await classifyThread(snapshot, userEmail, { skipDraft });
|
||||||
snapshot.importance = classification.importance;
|
snapshot.importance = classification.importance;
|
||||||
if (classification.summary) snapshot.summary = classification.summary;
|
if (classification.summary) snapshot.summary = classification.summary;
|
||||||
if (classification.draftResponse) snapshot.draft_response = classification.draftResponse;
|
if (classification.draftResponse) {
|
||||||
|
const draftResponse = stripGmailQuotedReplyText(classification.draftResponse);
|
||||||
|
if (draftResponse) snapshot.draft_response = draftResponse;
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn(`[Gmail] classify failed for ${threadId}:`, err);
|
console.warn(`[Gmail] classify failed for ${threadId}:`, err);
|
||||||
}
|
}
|
||||||
|
|
@ -947,16 +1060,20 @@ async function fullSync(auth: OAuth2Client, syncDir: string, attachmentsDir: str
|
||||||
// If the state file holds a last_sync timestamp (e.g. left over from a
|
// If the state file holds a last_sync timestamp (e.g. left over from a
|
||||||
// prior Composio sync, or from a previous successful native sync that
|
// prior Composio sync, or from a previous successful native sync that
|
||||||
// we're falling back to after a history.list 404), use that as the
|
// we're falling back to after a history.list 404), use that as the
|
||||||
// floor instead of the default lookback. Carries forward Composio's
|
// floor — but never reach back further than lookbackDays. This caps the
|
||||||
// last_sync on first migration so we don't refetch the last 7 days.
|
// window at "1 week at most": if last_sync is within the lookback window
|
||||||
|
// we resume from it (a smaller window), otherwise we clamp to lookbackDays
|
||||||
|
// ago. Mail older than the cap that arrived during a long offline gap is
|
||||||
|
// intentionally skipped rather than backfilled.
|
||||||
const state = loadState(stateFile);
|
const state = loadState(stateFile);
|
||||||
|
const lookbackFloor = new Date();
|
||||||
|
lookbackFloor.setDate(lookbackFloor.getDate() - lookbackDays);
|
||||||
let pastDate: Date;
|
let pastDate: Date;
|
||||||
if (state.last_sync) {
|
if (state.last_sync && new Date(state.last_sync) > lookbackFloor) {
|
||||||
pastDate = new Date(state.last_sync);
|
pastDate = new Date(state.last_sync);
|
||||||
console.log(`Performing full sync from last_sync=${state.last_sync}...`);
|
console.log(`Performing full sync from last_sync=${state.last_sync}...`);
|
||||||
} else {
|
} else {
|
||||||
pastDate = new Date();
|
pastDate = lookbackFloor;
|
||||||
pastDate.setDate(pastDate.getDate() - lookbackDays);
|
|
||||||
console.log(`Performing full sync of last ${lookbackDays} days...`);
|
console.log(`Performing full sync of last ${lookbackDays} days...`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1222,12 +1339,22 @@ async function performSync() {
|
||||||
// this runs once, the cache directory is populated and we fall back to
|
// this runs once, the cache directory is populated and we fall back to
|
||||||
// partial-sync on subsequent calls.
|
// partial-sync on subsequent calls.
|
||||||
const cacheMissing = !fs.existsSync(CACHE_DIR) || fs.readdirSync(CACHE_DIR).length === 0;
|
const cacheMissing = !fs.existsSync(CACHE_DIR) || fs.readdirSync(CACHE_DIR).length === 0;
|
||||||
|
// partialSync replays *every* messageAdded since the stored historyId,
|
||||||
|
// regardless of date — so after a long offline gap a still-valid
|
||||||
|
// historyId would pull the entire gap (e.g. 3 weeks). To honor the
|
||||||
|
// "1 week at most" cap, bypass it when last_sync is older than the
|
||||||
|
// lookback window and run a (date-clamped) fullSync instead.
|
||||||
|
const gapMs = state.last_sync ? Date.now() - new Date(state.last_sync).getTime() : 0;
|
||||||
|
const gapTooLarge = gapMs > LOOKBACK_DAYS * 24 * 60 * 60 * 1000;
|
||||||
if (!state.historyId) {
|
if (!state.historyId) {
|
||||||
console.log("No history ID found, starting full sync...");
|
console.log("No history ID found, starting full sync...");
|
||||||
await fullSync(auth, SYNC_DIR, ATTACHMENTS_DIR, STATE_FILE, LOOKBACK_DAYS);
|
await fullSync(auth, SYNC_DIR, ATTACHMENTS_DIR, STATE_FILE, LOOKBACK_DAYS);
|
||||||
} else if (cacheMissing) {
|
} else if (cacheMissing) {
|
||||||
console.log("History ID present but inbox cache empty — running full sync to backfill snapshots...");
|
console.log("History ID present but inbox cache empty — running full sync to backfill snapshots...");
|
||||||
await fullSync(auth, SYNC_DIR, ATTACHMENTS_DIR, STATE_FILE, LOOKBACK_DAYS);
|
await fullSync(auth, SYNC_DIR, ATTACHMENTS_DIR, STATE_FILE, LOOKBACK_DAYS);
|
||||||
|
} else if (gapTooLarge) {
|
||||||
|
console.log(`Last sync older than ${LOOKBACK_DAYS} days — running full sync clamped to the lookback window instead of partial sync...`);
|
||||||
|
await fullSync(auth, SYNC_DIR, ATTACHMENTS_DIR, STATE_FILE, LOOKBACK_DAYS);
|
||||||
} else {
|
} else {
|
||||||
console.log("History ID found, starting partial sync...");
|
console.log("History ID found, starting partial sync...");
|
||||||
await partialSync(auth, state.historyId, SYNC_DIR, ATTACHMENTS_DIR, STATE_FILE, LOOKBACK_DAYS);
|
await partialSync(auth, state.historyId, SYNC_DIR, ATTACHMENTS_DIR, STATE_FILE, LOOKBACK_DAYS);
|
||||||
|
|
@ -1330,6 +1457,10 @@ export async function sendThreadReply(opts: SendReplyOptions): Promise<SendReply
|
||||||
const safeBcc = opts.bcc?.trim() ? requireSafeHeaderValue('Bcc', opts.bcc) : undefined;
|
const safeBcc = opts.bcc?.trim() ? requireSafeHeaderValue('Bcc', opts.bcc) : undefined;
|
||||||
const safeInReplyTo = opts.inReplyTo ? requireSafeHeaderValue('In-Reply-To', opts.inReplyTo) : undefined;
|
const safeInReplyTo = opts.inReplyTo ? requireSafeHeaderValue('In-Reply-To', opts.inReplyTo) : undefined;
|
||||||
const safeReferences = opts.references ? requireSafeHeaderValue('References', opts.references) : undefined;
|
const safeReferences = opts.references ? requireSafeHeaderValue('References', opts.references) : undefined;
|
||||||
|
const replyBody = opts.threadId
|
||||||
|
? sanitizeReplyBodyForGmailReply(opts.bodyHtml, opts.bodyText)
|
||||||
|
: { bodyHtml: opts.bodyHtml.trim(), bodyText: opts.bodyText.trim() };
|
||||||
|
if (!replyBody.bodyText.trim()) return { error: 'Draft is empty.' };
|
||||||
|
|
||||||
const boundary = `b_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
|
const boundary = `b_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
|
||||||
const headers: string[] = [];
|
const headers: string[] = [];
|
||||||
|
|
@ -1348,13 +1479,13 @@ export async function sendThreadReply(opts: SendReplyOptions): Promise<SendReply
|
||||||
parts.push('Content-Type: text/plain; charset="UTF-8"');
|
parts.push('Content-Type: text/plain; charset="UTF-8"');
|
||||||
parts.push('Content-Transfer-Encoding: base64');
|
parts.push('Content-Transfer-Encoding: base64');
|
||||||
parts.push('');
|
parts.push('');
|
||||||
parts.push(encodeMimeBase64(opts.bodyText));
|
parts.push(encodeMimeBase64(replyBody.bodyText));
|
||||||
parts.push('');
|
parts.push('');
|
||||||
parts.push(`--${boundary}`);
|
parts.push(`--${boundary}`);
|
||||||
parts.push('Content-Type: text/html; charset="UTF-8"');
|
parts.push('Content-Type: text/html; charset="UTF-8"');
|
||||||
parts.push('Content-Transfer-Encoding: base64');
|
parts.push('Content-Transfer-Encoding: base64');
|
||||||
parts.push('');
|
parts.push('');
|
||||||
parts.push(encodeMimeBase64(opts.bodyHtml));
|
parts.push(encodeMimeBase64(replyBody.bodyHtml));
|
||||||
parts.push('');
|
parts.push('');
|
||||||
parts.push(`--${boundary}--`);
|
parts.push(`--${boundary}--`);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ const SIGNED_IN_DEFAULT_MODEL = "gpt-5.4";
|
||||||
const SIGNED_IN_DEFAULT_PROVIDER = "rowboat";
|
const SIGNED_IN_DEFAULT_PROVIDER = "rowboat";
|
||||||
const SIGNED_IN_KG_MODEL = "google/gemini-3.1-flash-lite";
|
const SIGNED_IN_KG_MODEL = "google/gemini-3.1-flash-lite";
|
||||||
const SIGNED_IN_LIVE_NOTE_AGENT_MODEL = "google/gemini-3.1-flash-lite";
|
const SIGNED_IN_LIVE_NOTE_AGENT_MODEL = "google/gemini-3.1-flash-lite";
|
||||||
|
const SIGNED_IN_AUTO_PERMISSION_DECISION_MODEL = "google/gemini-3.1-flash-lite";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The single source of truth for "what model+provider should we use when
|
* The single source of truth for "what model+provider should we use when
|
||||||
|
|
@ -76,6 +77,17 @@ export async function getLiveNoteAgentModel(): Promise<string> {
|
||||||
return cfg.liveNoteAgentModel ?? cfg.model;
|
return cfg.liveNoteAgentModel ?? cfg.model;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Model used by the auto-permission classifier.
|
||||||
|
* Signed-in: curated default. BYOK: user override
|
||||||
|
* (`autoPermissionDecisionModel`) or assistant model.
|
||||||
|
*/
|
||||||
|
export async function getAutoPermissionDecisionModel(): Promise<string> {
|
||||||
|
if (await isSignedIn()) return SIGNED_IN_AUTO_PERMISSION_DECISION_MODEL;
|
||||||
|
const cfg = await container.resolve<IModelConfigRepo>("modelConfigRepo").getConfig();
|
||||||
|
return cfg.autoPermissionDecisionModel ?? cfg.model;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Model used by the meeting-notes summarizer. No special signed-in default —
|
* Model used by the meeting-notes summarizer. No special signed-in default —
|
||||||
* historically meetings used the assistant model. BYOK: user override
|
* historically meetings used the assistant model. BYOK: user override
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,7 @@ export class FSModelConfigRepo implements IModelConfigRepo {
|
||||||
knowledgeGraphModel: config.knowledgeGraphModel,
|
knowledgeGraphModel: config.knowledgeGraphModel,
|
||||||
meetingNotesModel: config.meetingNotesModel,
|
meetingNotesModel: config.meetingNotesModel,
|
||||||
liveNoteAgentModel: config.liveNoteAgentModel,
|
liveNoteAgentModel: config.liveNoteAgentModel,
|
||||||
|
autoPermissionDecisionModel: config.autoPermissionDecisionModel,
|
||||||
};
|
};
|
||||||
|
|
||||||
const toWrite = { ...config, providers: existingProviders };
|
const toWrite = { ...config, providers: existingProviders };
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ export type CreateRunRepoOptions = {
|
||||||
agentId: string;
|
agentId: string;
|
||||||
model: string;
|
model: string;
|
||||||
provider: string;
|
provider: string;
|
||||||
|
permissionMode: "manual" | "auto";
|
||||||
useCase: z.infer<typeof UseCase>;
|
useCase: z.infer<typeof UseCase>;
|
||||||
subUseCase?: string;
|
subUseCase?: string;
|
||||||
};
|
};
|
||||||
|
|
@ -204,6 +205,7 @@ export class FSRunsRepo implements IRunsRepo {
|
||||||
agentName: options.agentId,
|
agentName: options.agentId,
|
||||||
model: options.model,
|
model: options.model,
|
||||||
provider: options.provider,
|
provider: options.provider,
|
||||||
|
permissionMode: options.permissionMode,
|
||||||
useCase: options.useCase,
|
useCase: options.useCase,
|
||||||
...(options.subUseCase ? { subUseCase: options.subUseCase } : {}),
|
...(options.subUseCase ? { subUseCase: options.subUseCase } : {}),
|
||||||
subflow: [],
|
subflow: [],
|
||||||
|
|
@ -216,6 +218,7 @@ export class FSRunsRepo implements IRunsRepo {
|
||||||
agentId: options.agentId,
|
agentId: options.agentId,
|
||||||
model: options.model,
|
model: options.model,
|
||||||
provider: options.provider,
|
provider: options.provider,
|
||||||
|
permissionMode: options.permissionMode,
|
||||||
useCase: options.useCase,
|
useCase: options.useCase,
|
||||||
...(options.subUseCase ? { subUseCase: options.subUseCase } : {}),
|
...(options.subUseCase ? { subUseCase: options.subUseCase } : {}),
|
||||||
log: [start],
|
log: [start],
|
||||||
|
|
@ -251,6 +254,7 @@ export class FSRunsRepo implements IRunsRepo {
|
||||||
agentId: start.agentName,
|
agentId: start.agentName,
|
||||||
model: start.model,
|
model: start.model,
|
||||||
provider: start.provider,
|
provider: start.provider,
|
||||||
|
permissionMode: start.permissionMode ?? "manual",
|
||||||
...(start.useCase ? { useCase: start.useCase } : {}),
|
...(start.useCase ? { useCase: start.useCase } : {}),
|
||||||
...(start.subUseCase ? { subUseCase: start.subUseCase } : {}),
|
...(start.subUseCase ? { subUseCase: start.subUseCase } : {}),
|
||||||
log: events,
|
log: events,
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ export async function createRun(opts: z.infer<typeof CreateRunOptions>): Promise
|
||||||
agentId: opts.agentId,
|
agentId: opts.agentId,
|
||||||
model,
|
model,
|
||||||
provider,
|
provider,
|
||||||
|
permissionMode: opts.permissionMode ?? "manual",
|
||||||
useCase,
|
useCase,
|
||||||
...(opts.subUseCase ? { subUseCase: opts.subUseCase } : {}),
|
...(opts.subUseCase ? { subUseCase: opts.subUseCase } : {}),
|
||||||
});
|
});
|
||||||
|
|
@ -39,9 +40,9 @@ export async function createRun(opts: z.infer<typeof CreateRunOptions>): Promise
|
||||||
return run;
|
return run;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createMessage(runId: string, message: UserMessageContentType, voiceInput?: boolean, voiceOutput?: VoiceOutputMode, searchEnabled?: boolean, middlePaneContext?: MiddlePaneContext): Promise<string> {
|
export async function createMessage(runId: string, message: UserMessageContentType, voiceInput?: boolean, voiceOutput?: VoiceOutputMode, searchEnabled?: boolean, middlePaneContext?: MiddlePaneContext, codeMode?: 'claude' | 'codex'): Promise<string> {
|
||||||
const queue = container.resolve<IMessageQueue>('messageQueue');
|
const queue = container.resolve<IMessageQueue>('messageQueue');
|
||||||
const id = await queue.enqueue(runId, message, voiceInput, voiceOutput, searchEnabled, middlePaneContext);
|
const id = await queue.enqueue(runId, message, voiceInput, voiceOutput, searchEnabled, middlePaneContext, codeMode);
|
||||||
const runtime = container.resolve<IAgentRuntime>('agentRuntime');
|
const runtime = container.resolve<IAgentRuntime>('agentRuntime');
|
||||||
runtime.trigger(runId);
|
runtime.trigger(runId);
|
||||||
return id;
|
return id;
|
||||||
|
|
|
||||||
112
apps/x/packages/core/src/security/auto-permission-classifier.ts
Normal file
112
apps/x/packages/core/src/security/auto-permission-classifier.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
import { generateObject, type ModelMessage } from "ai";
|
||||||
|
import z from "zod";
|
||||||
|
import { ToolPermissionMetadata } from "@x/shared/dist/runs.js";
|
||||||
|
import { ToolCallPart } from "@x/shared/dist/message.js";
|
||||||
|
import { captureLlmUsage } from "../analytics/usage.js";
|
||||||
|
import { withUseCase, type UseCase } from "../analytics/use_case.js";
|
||||||
|
import { getAutoPermissionDecisionModel, getDefaultModelAndProvider, resolveProviderConfig } from "../models/defaults.js";
|
||||||
|
import { createProvider } from "../models/models.js";
|
||||||
|
|
||||||
|
const DecisionSchema = z.object({
|
||||||
|
decisions: z.array(z.object({
|
||||||
|
toolCallId: z.string(),
|
||||||
|
decision: z.enum(["allow", "deny"]),
|
||||||
|
reason: z.string().min(1),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type AutoPermissionCandidate = {
|
||||||
|
toolCall: z.infer<typeof ToolCallPart>;
|
||||||
|
permission: z.infer<typeof ToolPermissionMetadata>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AutoPermissionDecision = {
|
||||||
|
toolCallId: string;
|
||||||
|
decision: "allow" | "deny";
|
||||||
|
reason: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SYSTEM_PROMPT = `You decide whether a personal productivity app may run tool calls without interrupting the user.
|
||||||
|
|
||||||
|
You only receive tool calls that already require permission under deterministic rules.
|
||||||
|
|
||||||
|
Allow a tool call only when it is clearly consistent with the user's request and low risk.
|
||||||
|
Deny tool calls that are destructive, credential-sensitive, privacy-sensitive, broad in scope, likely irreversible, or not clearly requested.
|
||||||
|
|
||||||
|
Command examples to deny unless explicitly requested: deleting data, force pushing, deploying, running migrations, changing permissions, reading secrets, exfiltrating tokens, or modifying files outside the user's workspace.
|
||||||
|
File examples to deny unless explicitly requested: deleting paths, writing outside the workspace, reading secrets or credentials, or broad access to private directories.
|
||||||
|
|
||||||
|
Return one decision for every toolCallId. Use the exact toolCallId values provided.`;
|
||||||
|
|
||||||
|
function compact(value: unknown, max = 8_000): string {
|
||||||
|
const text = typeof value === "string" ? value : JSON.stringify(value, null, 2);
|
||||||
|
if (text.length <= max) return text;
|
||||||
|
return `${text.slice(0, max)}\n...<truncated>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function recentContext(messages: ModelMessage[]): unknown[] {
|
||||||
|
return messages.slice(-8).map((message) => {
|
||||||
|
if (typeof message.content === "string") {
|
||||||
|
return { role: message.role, content: compact(message.content, 2_000) };
|
||||||
|
}
|
||||||
|
return { role: message.role, content: compact(message.content, 3_000) };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPrompt(input: {
|
||||||
|
agentName: string | null;
|
||||||
|
messages: ModelMessage[];
|
||||||
|
candidates: AutoPermissionCandidate[];
|
||||||
|
}) {
|
||||||
|
return compact({
|
||||||
|
agentName: input.agentName,
|
||||||
|
recentConversation: recentContext(input.messages),
|
||||||
|
toolCalls: input.candidates.map(({ toolCall, permission }) => ({
|
||||||
|
toolCallId: toolCall.toolCallId,
|
||||||
|
toolName: toolCall.toolName,
|
||||||
|
arguments: toolCall.arguments,
|
||||||
|
permission,
|
||||||
|
})),
|
||||||
|
}, 24_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function classifyToolPermissions(input: {
|
||||||
|
runId: string;
|
||||||
|
agentName: string | null;
|
||||||
|
messages: ModelMessage[];
|
||||||
|
candidates: AutoPermissionCandidate[];
|
||||||
|
useCase: UseCase;
|
||||||
|
subUseCase?: string | null;
|
||||||
|
}): Promise<AutoPermissionDecision[]> {
|
||||||
|
if (input.candidates.length === 0) return [];
|
||||||
|
|
||||||
|
const modelId = await getAutoPermissionDecisionModel();
|
||||||
|
const { provider: providerName } = await getDefaultModelAndProvider();
|
||||||
|
const providerConfig = await resolveProviderConfig(providerName);
|
||||||
|
const model = createProvider(providerConfig).languageModel(modelId);
|
||||||
|
|
||||||
|
const result = await withUseCase(
|
||||||
|
{
|
||||||
|
useCase: input.useCase,
|
||||||
|
subUseCase: "auto_permission_classifier",
|
||||||
|
...(input.agentName ? { agentName: input.agentName } : {}),
|
||||||
|
},
|
||||||
|
() => generateObject({
|
||||||
|
model,
|
||||||
|
system: SYSTEM_PROMPT,
|
||||||
|
prompt: buildPrompt(input),
|
||||||
|
schema: DecisionSchema,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
captureLlmUsage({
|
||||||
|
useCase: input.useCase,
|
||||||
|
subUseCase: "auto_permission_classifier",
|
||||||
|
model: modelId,
|
||||||
|
provider: providerName,
|
||||||
|
usage: result.usage,
|
||||||
|
});
|
||||||
|
|
||||||
|
const allowedIds = new Set(input.candidates.map((candidate) => candidate.toolCall.toolCallId));
|
||||||
|
return result.object.decisions.filter((decision) => allowedIds.has(decision.toolCallId));
|
||||||
|
}
|
||||||
70
apps/x/packages/shared/src/code-mode.ts
Normal file
70
apps/x/packages/shared/src/code-mode.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
import z from "zod";
|
||||||
|
|
||||||
|
// Shared zod schemas for the ACP code-mode engine. Single source of truth: the
|
||||||
|
// core engine re-exports the inferred TS types, and runs.ts builds the RunEvent
|
||||||
|
// variants that carry these to the renderer.
|
||||||
|
|
||||||
|
export const CodingAgent = z.enum(["claude", "codex"]);
|
||||||
|
export type CodingAgent = z.infer<typeof CodingAgent>;
|
||||||
|
|
||||||
|
// How the permission broker answers the agent's requests before any per-tool
|
||||||
|
// "always allow" memory is applied. `yolo` is the safe, scoped equivalent of
|
||||||
|
// `claude --dangerously-skip-permissions` (our toggle, not a CLI flag).
|
||||||
|
export const ApprovalPolicy = z.enum(["ask", "auto-approve-reads", "yolo"]);
|
||||||
|
export type ApprovalPolicy = z.infer<typeof ApprovalPolicy>;
|
||||||
|
|
||||||
|
export const PermissionDecision = z.enum(["allow_once", "allow_always", "reject"]);
|
||||||
|
export type PermissionDecision = z.infer<typeof PermissionDecision>;
|
||||||
|
|
||||||
|
// What the UI needs to render a permission card.
|
||||||
|
export const PermissionAsk = z.object({
|
||||||
|
toolCallId: z.string().optional(),
|
||||||
|
title: z.string(),
|
||||||
|
kind: z.string().optional(), // tool kind, e.g. "edit" | "execute" | "read"
|
||||||
|
isRead: z.boolean(),
|
||||||
|
});
|
||||||
|
export type PermissionAsk = z.infer<typeof PermissionAsk>;
|
||||||
|
|
||||||
|
// Normalized per-run stream items. The engine maps raw ACP session/update
|
||||||
|
// notifications onto this union; the renderer renders them.
|
||||||
|
export const CodeRunEvent = z.discriminatedUnion("type", [
|
||||||
|
// role distinguishes the agent's own output from replayed user turns
|
||||||
|
// (loadSession streams the whole prior conversation back on resume).
|
||||||
|
z.object({ type: z.literal("message"), role: z.enum(["agent", "user"]), text: z.string() }),
|
||||||
|
z.object({ type: z.literal("thought") }),
|
||||||
|
z.object({
|
||||||
|
type: z.literal("tool_call"),
|
||||||
|
id: z.string().optional(),
|
||||||
|
title: z.string().optional(),
|
||||||
|
kind: z.string().optional(),
|
||||||
|
status: z.string().optional(),
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
type: z.literal("tool_call_update"),
|
||||||
|
id: z.string().optional(),
|
||||||
|
status: z.string().optional(),
|
||||||
|
diffs: z.array(z.string()),
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
type: z.literal("plan"),
|
||||||
|
entries: z.array(z.object({
|
||||||
|
content: z.string(),
|
||||||
|
status: z.string().optional(),
|
||||||
|
priority: z.string().optional(),
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
type: z.literal("permission"),
|
||||||
|
ask: PermissionAsk,
|
||||||
|
decision: z.union([PermissionDecision, z.literal("cancelled")]),
|
||||||
|
auto: z.boolean(),
|
||||||
|
}),
|
||||||
|
z.object({ type: z.literal("other"), sessionUpdate: z.string() }),
|
||||||
|
]);
|
||||||
|
export type CodeRunEvent = z.infer<typeof CodeRunEvent>;
|
||||||
|
|
||||||
|
export const RunPromptResult = z.object({
|
||||||
|
stopReason: z.string(),
|
||||||
|
sessionId: z.string(),
|
||||||
|
});
|
||||||
|
export type RunPromptResult = z.infer<typeof RunPromptResult>;
|
||||||
|
|
@ -19,6 +19,7 @@ import { ZListToolkitsResponse } from './composio.js';
|
||||||
import { BrowserStateSchema } from './browser-control.js';
|
import { BrowserStateSchema } from './browser-control.js';
|
||||||
import { BillingInfoSchema } from './billing.js';
|
import { BillingInfoSchema } from './billing.js';
|
||||||
import { EmailBlockSchema, GmailThreadSchema } from './blocks.js';
|
import { EmailBlockSchema, GmailThreadSchema } from './blocks.js';
|
||||||
|
import { PermissionDecision, ApprovalPolicy } from './code-mode.js';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Runtime Validation Schemas (Single Source of Truth)
|
// Runtime Validation Schemas (Single Source of Truth)
|
||||||
|
|
@ -38,6 +39,7 @@ const ipcSchemas = {
|
||||||
res: z.object({
|
res: z.object({
|
||||||
installationId: z.string(),
|
installationId: z.string(),
|
||||||
apiUrl: z.string(),
|
apiUrl: z.string(),
|
||||||
|
appVersion: z.string(),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
'workspace:getRoot': {
|
'workspace:getRoot': {
|
||||||
|
|
@ -228,6 +230,7 @@ const ipcSchemas = {
|
||||||
voiceInput: z.boolean().optional(),
|
voiceInput: z.boolean().optional(),
|
||||||
voiceOutput: z.enum(['summary', 'full']).optional(),
|
voiceOutput: z.enum(['summary', 'full']).optional(),
|
||||||
searchEnabled: z.boolean().optional(),
|
searchEnabled: z.boolean().optional(),
|
||||||
|
codeMode: z.enum(['claude', 'codex']).optional(),
|
||||||
middlePaneContext: z.discriminatedUnion('kind', [
|
middlePaneContext: z.discriminatedUnion('kind', [
|
||||||
z.object({
|
z.object({
|
||||||
kind: z.literal('note'),
|
kind: z.literal('note'),
|
||||||
|
|
@ -290,6 +293,15 @@ const ipcSchemas = {
|
||||||
}),
|
}),
|
||||||
res: z.object({ success: z.boolean() }),
|
res: z.object({ success: z.boolean() }),
|
||||||
},
|
},
|
||||||
|
'runs:downloadLog': {
|
||||||
|
req: z.object({
|
||||||
|
runId: z.string().min(1),
|
||||||
|
}),
|
||||||
|
res: z.object({
|
||||||
|
success: z.boolean(),
|
||||||
|
error: z.string().optional(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
'runs:events': {
|
'runs:events': {
|
||||||
req: z.null(),
|
req: z.null(),
|
||||||
res: z.null(),
|
res: z.null(),
|
||||||
|
|
@ -395,13 +407,11 @@ const ipcSchemas = {
|
||||||
},
|
},
|
||||||
'app:takeMeetingNotes': {
|
'app:takeMeetingNotes': {
|
||||||
req: z.object({
|
req: z.object({
|
||||||
// Calendar event JSON when correlated; null for mic-detect ad-hoc fires.
|
// Pass the raw calendar event JSON through; renderer adapts to its existing flow.
|
||||||
event: z.unknown(),
|
event: z.unknown(),
|
||||||
// When true, the renderer should also open the meeting URL (Zoom/Meet/etc.)
|
// When true, the renderer should also open the meeting URL (Zoom/Meet/etc.)
|
||||||
// in addition to triggering the take-notes flow.
|
// in addition to triggering the take-notes flow.
|
||||||
openMeeting: z.boolean().optional(),
|
openMeeting: z.boolean().optional(),
|
||||||
// Fallback title for ad-hoc detection (no calendar event matched).
|
|
||||||
title: z.string().nullable().optional(),
|
|
||||||
}),
|
}),
|
||||||
res: z.null(),
|
res: z.null(),
|
||||||
},
|
},
|
||||||
|
|
@ -417,6 +427,39 @@ const ipcSchemas = {
|
||||||
enabled: z.boolean(),
|
enabled: z.boolean(),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
'codeMode:getConfig': {
|
||||||
|
req: z.null(),
|
||||||
|
res: z.object({
|
||||||
|
enabled: z.boolean(),
|
||||||
|
approvalPolicy: ApprovalPolicy.optional(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
'codeMode:setConfig': {
|
||||||
|
req: z.object({
|
||||||
|
enabled: z.boolean(),
|
||||||
|
approvalPolicy: ApprovalPolicy.optional(),
|
||||||
|
}),
|
||||||
|
res: z.object({
|
||||||
|
success: z.literal(true),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
// Answer a mid-run permission request from a code_agent_run coding turn.
|
||||||
|
'codeRun:resolvePermission': {
|
||||||
|
req: z.object({
|
||||||
|
requestId: z.string(),
|
||||||
|
decision: PermissionDecision,
|
||||||
|
}),
|
||||||
|
res: z.object({
|
||||||
|
success: z.literal(true),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
'codeMode:checkAgentStatus': {
|
||||||
|
req: z.null(),
|
||||||
|
res: z.object({
|
||||||
|
claude: z.object({ installed: z.boolean(), signedIn: z.boolean() }),
|
||||||
|
codex: z.object({ installed: z.boolean(), signedIn: z.boolean() }),
|
||||||
|
}),
|
||||||
|
},
|
||||||
'granola:setConfig': {
|
'granola:setConfig': {
|
||||||
req: z.object({
|
req: z.object({
|
||||||
enabled: z.boolean(),
|
enabled: z.boolean(),
|
||||||
|
|
|
||||||
|
|
@ -50,9 +50,29 @@ export const UserContentPart = z.union([UserTextPart, UserAttachmentPart]);
|
||||||
// Named type for user message content — used everywhere instead of repeating the union
|
// Named type for user message content — used everywhere instead of repeating the union
|
||||||
export const UserMessageContent = z.union([z.string(), z.array(UserContentPart)]);
|
export const UserMessageContent = z.union([z.string(), z.array(UserContentPart)]);
|
||||||
|
|
||||||
|
export const UserMessageContext = z.object({
|
||||||
|
currentDateTime: z.string().optional(),
|
||||||
|
middlePane: z.discriminatedUnion("kind", [
|
||||||
|
z.object({
|
||||||
|
kind: z.literal("empty"),
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
kind: z.literal("note"),
|
||||||
|
path: z.string(),
|
||||||
|
content: z.string(),
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
kind: z.literal("browser"),
|
||||||
|
url: z.string(),
|
||||||
|
title: z.string(),
|
||||||
|
}),
|
||||||
|
]).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
export const UserMessage = z.object({
|
export const UserMessage = z.object({
|
||||||
role: z.literal("user"),
|
role: z.literal("user"),
|
||||||
content: UserMessageContent,
|
content: UserMessageContent,
|
||||||
|
userMessageContext: UserMessageContext.optional(),
|
||||||
providerOptions: ProviderOptions.optional(),
|
providerOptions: ProviderOptions.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,10 +17,15 @@ export const LlmModelConfig = z.object({
|
||||||
headers: z.record(z.string(), z.string()).optional(),
|
headers: z.record(z.string(), z.string()).optional(),
|
||||||
model: z.string().optional(),
|
model: z.string().optional(),
|
||||||
models: z.array(z.string()).optional(),
|
models: z.array(z.string()).optional(),
|
||||||
|
knowledgeGraphModel: z.string().optional(),
|
||||||
|
meetingNotesModel: z.string().optional(),
|
||||||
|
liveNoteAgentModel: z.string().optional(),
|
||||||
|
autoPermissionDecisionModel: z.string().optional(),
|
||||||
})).optional(),
|
})).optional(),
|
||||||
// Per-category model overrides (BYOK only — signed-in users always get
|
// Per-category model overrides (BYOK only — signed-in users always get
|
||||||
// the curated gateway defaults). Read by helpers in core/models/defaults.ts.
|
// the curated gateway defaults). Read by helpers in core/models/defaults.ts.
|
||||||
knowledgeGraphModel: z.string().optional(),
|
knowledgeGraphModel: z.string().optional(),
|
||||||
meetingNotesModel: z.string().optional(),
|
meetingNotesModel: z.string().optional(),
|
||||||
liveNoteAgentModel: z.string().optional(),
|
liveNoteAgentModel: z.string().optional(),
|
||||||
|
autoPermissionDecisionModel: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { LlmStepStreamEvent } from "./llm-step-events.js";
|
import { LlmStepStreamEvent } from "./llm-step-events.js";
|
||||||
import { Message, ToolCallPart } from "./message.js";
|
import { Message, ToolCallPart } from "./message.js";
|
||||||
|
import { CodeRunEvent as CodeRunEventSchema, PermissionAsk } from "./code-mode.js";
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
|
|
||||||
const BaseRunEvent = z.object({
|
const BaseRunEvent = z.object({
|
||||||
|
|
@ -21,6 +22,7 @@ export const StartEvent = BaseRunEvent.extend({
|
||||||
agentName: z.string(),
|
agentName: z.string(),
|
||||||
model: z.string(),
|
model: z.string(),
|
||||||
provider: z.string(),
|
provider: z.string(),
|
||||||
|
permissionMode: z.enum(["manual", "auto"]).optional(),
|
||||||
// useCase/subUseCase tag the run for analytics. Optional on read so legacy
|
// useCase/subUseCase tag the run for analytics. Optional on read so legacy
|
||||||
// run files written before these fields existed still parse cleanly.
|
// run files written before these fields existed still parse cleanly.
|
||||||
useCase: z.enum([
|
useCase: z.enum([
|
||||||
|
|
@ -75,6 +77,7 @@ export const AskHumanRequestEvent = BaseRunEvent.extend({
|
||||||
type: z.literal("ask-human-request"),
|
type: z.literal("ask-human-request"),
|
||||||
toolCallId: z.string(),
|
toolCallId: z.string(),
|
||||||
query: z.string(),
|
query: z.string(),
|
||||||
|
options: z.array(z.string()).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const AskHumanResponseEvent = BaseRunEvent.extend({
|
export const AskHumanResponseEvent = BaseRunEvent.extend({
|
||||||
|
|
@ -109,6 +112,32 @@ export const ToolPermissionResponseEvent = BaseRunEvent.extend({
|
||||||
scope: z.enum(["once", "session", "always"]).optional(),
|
scope: z.enum(["once", "session", "always"]).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// A structured item from a code_agent_run coding turn (tool call, diff, plan,
|
||||||
|
// message chunk, resolved permission). Fire-and-forget — rendered live.
|
||||||
|
export const CodeRunStreamEvent = BaseRunEvent.extend({
|
||||||
|
type: z.literal("code-run-event"),
|
||||||
|
toolCallId: z.string(),
|
||||||
|
event: CodeRunEventSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
// The coding agent is asking for permission mid-turn and the run is BLOCKED until
|
||||||
|
// the user answers via `codeRun:resolvePermission` (keyed by requestId).
|
||||||
|
export const CodeRunPermissionRequestEvent = BaseRunEvent.extend({
|
||||||
|
type: z.literal("code-run-permission-request"),
|
||||||
|
toolCallId: z.string(),
|
||||||
|
requestId: z.string(),
|
||||||
|
ask: PermissionAsk,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ToolPermissionAutoDecisionEvent = BaseRunEvent.extend({
|
||||||
|
type: z.literal("tool-permission-auto-decision"),
|
||||||
|
toolCallId: z.string(),
|
||||||
|
toolCall: ToolCallPart,
|
||||||
|
permission: ToolPermissionMetadata.optional(),
|
||||||
|
decision: z.enum(["allow", "deny"]),
|
||||||
|
reason: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
export const RunErrorEvent = BaseRunEvent.extend({
|
export const RunErrorEvent = BaseRunEvent.extend({
|
||||||
type: z.literal("error"),
|
type: z.literal("error"),
|
||||||
error: z.string(),
|
error: z.string(),
|
||||||
|
|
@ -133,6 +162,9 @@ export const RunEvent = z.union([
|
||||||
AskHumanResponseEvent,
|
AskHumanResponseEvent,
|
||||||
ToolPermissionRequestEvent,
|
ToolPermissionRequestEvent,
|
||||||
ToolPermissionResponseEvent,
|
ToolPermissionResponseEvent,
|
||||||
|
CodeRunStreamEvent,
|
||||||
|
CodeRunPermissionRequestEvent,
|
||||||
|
ToolPermissionAutoDecisionEvent,
|
||||||
RunErrorEvent,
|
RunErrorEvent,
|
||||||
RunStoppedEvent,
|
RunStoppedEvent,
|
||||||
]);
|
]);
|
||||||
|
|
@ -165,6 +197,7 @@ export const Run = z.object({
|
||||||
agentId: z.string(),
|
agentId: z.string(),
|
||||||
model: z.string(),
|
model: z.string(),
|
||||||
provider: z.string(),
|
provider: z.string(),
|
||||||
|
permissionMode: z.enum(["manual", "auto"]).optional(),
|
||||||
useCase: UseCase.optional(),
|
useCase: UseCase.optional(),
|
||||||
subUseCase: z.string().optional(),
|
subUseCase: z.string().optional(),
|
||||||
log: z.array(RunEvent),
|
log: z.array(RunEvent),
|
||||||
|
|
@ -184,6 +217,7 @@ export const CreateRunOptions = z.object({
|
||||||
agentId: z.string(),
|
agentId: z.string(),
|
||||||
model: z.string().optional(),
|
model: z.string().optional(),
|
||||||
provider: z.string().optional(),
|
provider: z.string().optional(),
|
||||||
|
permissionMode: z.enum(["manual", "auto"]).optional(),
|
||||||
useCase: UseCase.optional(),
|
useCase: UseCase.optional(),
|
||||||
subUseCase: z.string().optional(),
|
subUseCase: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
15
apps/x/patches/@openai__codex@0.128.0.patch
Normal file
15
apps/x/patches/@openai__codex@0.128.0.patch
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
diff --git a/bin/codex.js b/bin/codex.js
|
||||||
|
index 67ab3e2d95dfac1c91882578b5403916c3121484..f8030b6e1459e05161af99e152b2e7f65ea6c41d 100644
|
||||||
|
--- a/bin/codex.js
|
||||||
|
+++ b/bin/codex.js
|
||||||
|
@@ -175,6 +175,10 @@ env[packageManagerEnvVar] = "1";
|
||||||
|
const child = spawn(binaryPath, process.argv.slice(2), {
|
||||||
|
stdio: "inherit",
|
||||||
|
env,
|
||||||
|
+ // Native console-subsystem binary: without this Windows pops a visible console
|
||||||
|
+ // window when launched from a console-less (Electron GUI) parent. Closing that
|
||||||
|
+ // window wedges the agent. CREATE_NO_WINDOW keeps the console hidden.
|
||||||
|
+ windowsHide: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on("error", (err) => {
|
||||||
1200
apps/x/pnpm-lock.yaml
generated
1200
apps/x/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
|
@ -13,3 +13,5 @@ onlyBuiltDependencies:
|
||||||
- fs-xattr
|
- fs-xattr
|
||||||
- macos-alias
|
- macos-alias
|
||||||
- protobufjs
|
- protobufjs
|
||||||
|
patchedDependencies:
|
||||||
|
'@openai/codex@0.128.0': patches/@openai__codex@0.128.0.patch
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue