mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-06-15 20:05:16 +02:00
Merge origin/dev into fix/code-mode-packaged-adapters (resolve forge ignore + manager suppressReplay/cleanup)
This commit is contained in:
commit
e7f03c3fa0
67 changed files with 7100 additions and 232 deletions
|
|
@ -24,7 +24,7 @@ Emitted whenever ai-sdk returns token usage (one event per LLM call, not per run
|
|||
|
||||
| Property | Type | Notes |
|
||||
|---|---|---|
|
||||
| `use_case` | enum | `copilot_chat` / `live_note_agent` / `meeting_note` / `knowledge_sync` |
|
||||
| `use_case` | enum | `copilot_chat` / `live_note_agent` / `meeting_note` / `knowledge_sync` / `code_session` |
|
||||
| `sub_use_case` | string? | Refines `use_case` — see taxonomy table below |
|
||||
| `agent_name` | string? | Present when the call goes through an agent run (`createRun`); omitted for direct `generateText`/`generateObject` |
|
||||
| `model` | string | e.g. `claude-sonnet-4-6` |
|
||||
|
|
@ -57,6 +57,7 @@ Every `llm_usage` emit point in the codebase:
|
|||
| `knowledge_sync` | `inline_task_run` | yes | Inline `@rowboat` task execution (two call sites) | `packages/core/src/knowledge/inline_tasks.ts:471, 552` (createRun) |
|
||||
| `knowledge_sync` | `inline_task_classify` | no | Inline task scheduling classifier (`generateText`) | `packages/core/src/knowledge/inline_tasks.ts:673` |
|
||||
| `knowledge_sync` | `pre_built` | yes | Pre-built scheduled agents | `packages/core/src/pre_built/runner.ts:43` (createRun) |
|
||||
| `code_session` | (none) | yes | Code-section coding session in Rowboat mode (direct mode talks to the on-device coding agent and emits no `llm_usage`) | `packages/core/src/code-mode/sessions/service.ts` (createRun) |
|
||||
|
||||
##### `live_note_agent` sub-use-case shape
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,9 @@
|
|||
|
||||
import * as esbuild from 'esbuild';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
// 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,
|
||||
|
|
@ -24,7 +27,11 @@ await esbuild.build({
|
|||
platform: 'node',
|
||||
target: 'node20',
|
||||
outfile: './.package/dist/main.cjs',
|
||||
external: ['electron'], // Provided by Electron runtime
|
||||
// electron is provided by the runtime. node-pty is a NATIVE module: it can't
|
||||
// be inlined (its loader requires .node binaries + a spawn-helper relative to
|
||||
// its own package dir), so it stays external and is copied into
|
||||
// .package/node_modules below, where require() from dist/main.cjs finds it.
|
||||
external: ['electron', 'node-pty'],
|
||||
// Use CommonJS format - many dependencies use require() which doesn't work
|
||||
// well with esbuild's ESM shim. CJS handles dynamic requires natively.
|
||||
format: 'cjs',
|
||||
|
|
@ -42,4 +49,23 @@ await esbuild.build({
|
|||
},
|
||||
});
|
||||
|
||||
// Ship node-pty next to the bundle. Resolve through pnpm's symlink to the real
|
||||
// package dir and copy only what's needed at runtime (compiled JS + prebuilt
|
||||
// binaries). The macOS spawn-helper must be executable — pnpm extraction drops
|
||||
// the bit, and a non-executable helper makes every PTY spawn fail.
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ptySrc = fs.realpathSync(path.join(here, 'node_modules', 'node-pty'));
|
||||
const ptyDest = path.join(here, '.package', 'node_modules', 'node-pty');
|
||||
fs.rmSync(ptyDest, { recursive: true, force: true });
|
||||
fs.mkdirSync(ptyDest, { recursive: true });
|
||||
for (const item of ['package.json', 'lib', 'prebuilds']) {
|
||||
fs.cpSync(path.join(ptySrc, item), path.join(ptyDest, item), { recursive: true, dereference: true });
|
||||
}
|
||||
const prebuildsDir = path.join(ptyDest, 'prebuilds');
|
||||
for (const dir of fs.readdirSync(prebuildsDir)) {
|
||||
const helper = path.join(prebuildsDir, dir, 'spawn-helper');
|
||||
if (fs.existsSync(helper)) fs.chmodSync(helper, 0o755);
|
||||
}
|
||||
console.log('✅ node-pty staged in .package/node_modules');
|
||||
|
||||
console.log('✅ Main process bundled to .package/dist-bundle/main.js');
|
||||
|
|
|
|||
|
|
@ -128,14 +128,16 @@ module.exports = {
|
|||
// from trying to analyze/copy node_modules, which fails with pnpm's symlinked
|
||||
// workspaces.
|
||||
prune: false,
|
||||
// Strip the workspace node_modules, BUT always keep everything under `.package/`
|
||||
// — that's our staged output, which now also includes the ACP adapters + their
|
||||
// dependency closure (staged by the generateAssets hook). Without the `.package`
|
||||
// exemption the /node_modules/ rule would strip the staged adapters and code mode
|
||||
// Strip the workspace src/node_modules (regexes ANCHORED to the app root), BUT
|
||||
// always keep everything under `.package/` — that's our staged output: the
|
||||
// bundled main process, the ACP adapters + their dependency closure (staged by
|
||||
// the generateAssets hook), and the native node-pty module (staged into
|
||||
// .package/node_modules by bundle.mjs). Without the `.package` exemption the
|
||||
// node_modules rule would strip those and code mode / the embedded terminal
|
||||
// would break in packaged builds.
|
||||
ignore: (p) => {
|
||||
if (p === '/.package' || p.startsWith('/.package/')) return false;
|
||||
return [/src\//, /node_modules\//, /\.gitignore/, /bundle\.mjs/, /tsconfig\.json/]
|
||||
return [/^\/src\//, /^\/node_modules\//, /\.gitignore/, /bundle\.mjs/, /tsconfig\.json/]
|
||||
.some((re) => re.test(p));
|
||||
},
|
||||
},
|
||||
|
|
@ -184,6 +186,21 @@ module.exports = {
|
|||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: require.resolve('./makers/maker-pacman.cjs'),
|
||||
platforms: ['linux'],
|
||||
config: {
|
||||
name: 'rowboat',
|
||||
bin: 'rowboat',
|
||||
executableName: 'rowboat',
|
||||
description: 'AI coworker with memory',
|
||||
maintainer: 'rowboatlabs',
|
||||
homepage: 'https://rowboatlabs.com',
|
||||
license: 'Apache',
|
||||
icon: path.join(__dirname, 'icons/icon.png'),
|
||||
mimeType: ['x-scheme-handler/rowboat'],
|
||||
}
|
||||
},
|
||||
{
|
||||
name: '@electron-forge/maker-zip',
|
||||
platform: ["darwin", "win32", "linux"],
|
||||
|
|
|
|||
134
apps/x/apps/main/makers/maker-pacman.cjs
Normal file
134
apps/x/apps/main/makers/maker-pacman.cjs
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
// Custom Electron Forge maker that produces Arch Linux .pkg.tar.zst packages
|
||||
// via makepkg. Runs only on Linux with makepkg available (i.e. an Arch host).
|
||||
//
|
||||
// CJS on purpose: forge.config.cjs require()s us.
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { execSync } = require('child_process');
|
||||
|
||||
const MakerBase = require('@electron-forge/maker-base').default;
|
||||
|
||||
const ARCH_MAP = { x64: 'x86_64', arm64: 'aarch64', ia32: 'i686', armv7l: 'armv7h' };
|
||||
|
||||
class MakerPacman extends MakerBase {
|
||||
name = 'pacman';
|
||||
defaultPlatforms = ['linux'];
|
||||
|
||||
isSupportedOnCurrentPlatform() {
|
||||
if (process.platform !== 'linux') return false;
|
||||
try {
|
||||
execSync('command -v makepkg', { stdio: 'ignore' });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async make({ dir, makeDir, targetArch, packageJSON, appName }) {
|
||||
const pkgArch = ARCH_MAP[targetArch] || targetArch;
|
||||
|
||||
const cfg = this.config || {};
|
||||
const pkgName = (cfg.name || appName || packageJSON.name).toLowerCase();
|
||||
// pacman pkgver disallows '-'; map prerelease tags through.
|
||||
const pkgVersion = String(packageJSON.version || '0.0.0').replace(/-/g, '_');
|
||||
const pkgDesc = (cfg.description || packageJSON.description || '').replace(/"/g, '\\"');
|
||||
const maintainer = cfg.maintainer || 'unknown';
|
||||
const homepage = cfg.homepage || packageJSON.homepage || '';
|
||||
const license = cfg.license || 'custom';
|
||||
const bin = cfg.bin || pkgName;
|
||||
const execName = cfg.executableName || appName || pkgName;
|
||||
const mimeTypes = cfg.mimeType || [];
|
||||
const depends = cfg.depends || [];
|
||||
const iconSrc = cfg.icon;
|
||||
|
||||
const outDir = path.resolve(path.join(makeDir, 'pacman', targetArch));
|
||||
await this.ensureDirectory(outDir);
|
||||
|
||||
// Clean prior contents so makepkg starts fresh each run.
|
||||
for (const f of fs.readdirSync(outDir)) {
|
||||
fs.rmSync(path.join(outDir, f), { recursive: true, force: true });
|
||||
}
|
||||
|
||||
// Wrapper script — execs the packaged Electron binary, forwards args (incl. rowboat:// URLs).
|
||||
fs.writeFileSync(
|
||||
path.join(outDir, bin),
|
||||
`#!/bin/sh\nexec "/opt/${pkgName}/${execName}" "$@"\n`,
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
|
||||
const desktop = [
|
||||
'[Desktop Entry]',
|
||||
`Name=${appName || pkgName}`,
|
||||
`Comment=${pkgDesc}`,
|
||||
`Exec=${bin} %U`,
|
||||
`Icon=${pkgName}`,
|
||||
'Type=Application',
|
||||
'Categories=Utility;',
|
||||
'Terminal=false',
|
||||
mimeTypes.length ? `MimeType=${mimeTypes.join(';')};` : null,
|
||||
'',
|
||||
].filter(Boolean).join('\n');
|
||||
fs.writeFileSync(path.join(outDir, `${pkgName}.desktop`), desktop);
|
||||
|
||||
const sources = [bin, `${pkgName}.desktop`];
|
||||
let iconInstall = '';
|
||||
if (iconSrc && fs.existsSync(iconSrc)) {
|
||||
fs.copyFileSync(iconSrc, path.join(outDir, 'icon.png'));
|
||||
sources.push('icon.png');
|
||||
iconInstall = ` install -Dm644 "$srcdir/icon.png" "$pkgdir/usr/share/icons/hicolor/512x512/apps/${pkgName}.png"`;
|
||||
}
|
||||
|
||||
const sumsLine = sources.map(() => "'SKIP'").join(' ');
|
||||
const sourceLine = sources.map((s) => `'${s}'`).join(' ');
|
||||
const dependsLine = depends.map((d) => `'${d}'`).join(' ');
|
||||
// Embed the packager output dir as a bash-safe literal.
|
||||
const appDirEscaped = dir.replace(/'/g, `'\\''`);
|
||||
|
||||
const pkgbuild = `# Maintainer: ${maintainer}
|
||||
# Auto-generated by maker-pacman.cjs — do not edit by hand.
|
||||
pkgname=${pkgName}
|
||||
pkgver=${pkgVersion}
|
||||
pkgrel=1
|
||||
pkgdesc="${pkgDesc}"
|
||||
arch=('${pkgArch}')
|
||||
url="${homepage}"
|
||||
license=('${license}')
|
||||
depends=(${dependsLine})
|
||||
options=('!strip' '!debug')
|
||||
source=(${sourceLine})
|
||||
sha256sums=(${sumsLine})
|
||||
|
||||
_appdir='${appDirEscaped}'
|
||||
|
||||
package() {
|
||||
install -dm755 "$pkgdir/opt/$pkgname"
|
||||
cp -a "$_appdir/." "$pkgdir/opt/$pkgname/"
|
||||
|
||||
# Electron's sandbox helper needs setuid root for the namespace sandbox.
|
||||
if [ -f "$pkgdir/opt/$pkgname/chrome-sandbox" ]; then
|
||||
chmod 4755 "$pkgdir/opt/$pkgname/chrome-sandbox"
|
||||
fi
|
||||
|
||||
install -Dm755 "$srcdir/${bin}" "$pkgdir/usr/bin/${bin}"
|
||||
install -Dm644 "$srcdir/${pkgName}.desktop" "$pkgdir/usr/share/applications/${pkgName}.desktop"
|
||||
${iconInstall}
|
||||
}
|
||||
`;
|
||||
fs.writeFileSync(path.join(outDir, 'PKGBUILD'), pkgbuild);
|
||||
|
||||
execSync('makepkg -f --noconfirm --nodeps', {
|
||||
cwd: outDir,
|
||||
stdio: 'inherit',
|
||||
env: { ...process.env, PKGEXT: '.pkg.tar.zst', CARCH: pkgArch },
|
||||
});
|
||||
|
||||
return fs
|
||||
.readdirSync(outDir)
|
||||
.filter((f) => f.endsWith('.pkg.tar.zst'))
|
||||
.map((f) => path.join(outDir, f));
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = MakerPacman;
|
||||
module.exports.default = MakerPacman;
|
||||
|
|
@ -21,6 +21,7 @@
|
|||
"electron-squirrel-startup": "^1.0.1",
|
||||
"html-to-docx": "^1.8.0",
|
||||
"mammoth": "^1.11.0",
|
||||
"node-pty": "^1.1.0",
|
||||
"papaparse": "^5.5.3",
|
||||
"pdf-parse": "^2.4.5",
|
||||
"update-electron-app": "^3.1.2",
|
||||
|
|
@ -29,6 +30,7 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@electron-forge/cli": "^7.10.2",
|
||||
"@electron-forge/maker-base": "^7.11.1",
|
||||
"@electron-forge/maker-deb": "^7.11.1",
|
||||
"@electron-forge/maker-dmg": "^7.10.2",
|
||||
"@electron-forge/maker-rpm": "^7.11.1",
|
||||
|
|
|
|||
|
|
@ -34,10 +34,19 @@ 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 type { ICodeProjectsRepo } from '@x/core/dist/code-mode/projects/repo.js';
|
||||
import type { ICodeSessionsRepo } from '@x/core/dist/code-mode/sessions/repo.js';
|
||||
import { CodeSessionService } from '@x/core/dist/code-mode/sessions/service.js';
|
||||
import { CodeSessionStatusTracker } from '@x/core/dist/code-mode/sessions/status-tracker.js';
|
||||
import * as codeGit from '@x/core/dist/code-mode/git/service.js';
|
||||
import { readProjectDir, readProjectFile } from '@x/core/dist/code-mode/projects/fs.js';
|
||||
import { ensureTerminal, writeTerminal, resizeTerminal, disposeTerminal } from './terminal.js';
|
||||
import type { CodeSession } from '@x/shared/dist/code-sessions.js';
|
||||
import { invalidateCopilotInstructionsCache } from '@x/core/dist/application/assistant/instructions.js';
|
||||
import { triggerSync as triggerGranolaSync } from '@x/core/dist/knowledge/granola/sync.js';
|
||||
import { ISlackConfigRepo } from '@x/core/dist/slack/repo.js';
|
||||
import { isOnboardingComplete, markOnboardingComplete } from '@x/core/dist/config/note_creation_config.js';
|
||||
import { loadNotificationSettings, saveNotificationSettings } from '@x/core/dist/config/notification_config.js';
|
||||
import * as composioHandler from './composio-handler.js';
|
||||
import { consumePendingDeepLink } from './deeplink.js';
|
||||
import { qualifyAndDisconnectComposioGoogle } from '@x/core/dist/migrations/composio-google-migration.js';
|
||||
|
|
@ -53,6 +62,8 @@ import { getAccessToken } from '@x/core/dist/auth/tokens.js';
|
|||
import { getRowboatConfig } from '@x/core/dist/config/rowboat.js';
|
||||
import { runLiveNoteAgent } from '@x/core/dist/knowledge/live-note/runner.js';
|
||||
import { listImportantThreads, listEverythingElseThreads, saveMessageBodyHeight, triggerSync as triggerGmailSync, sendThreadReply, archiveThread, trashThread, markThreadRead, getAccountEmail, getConnectionStatus as getGmailConnectionStatus } from '@x/core/dist/knowledge/sync_gmail.js';
|
||||
import { searchContacts as searchGmailContacts, warmContactIndex } from '@x/core/dist/knowledge/gmail_contacts.js';
|
||||
import { searchSentContacts, warmSentContacts } from '@x/core/dist/knowledge/gmail_sent_contacts.js';
|
||||
import { liveNoteBus } from '@x/core/dist/knowledge/live-note/bus.js';
|
||||
import { getInstallationId } from '@x/core/dist/analytics/installation.js';
|
||||
import { API_URL } from '@x/core/dist/config/env.js';
|
||||
|
|
@ -372,6 +383,32 @@ export function emitOAuthEvent(event: { provider: string; success: boolean; erro
|
|||
}
|
||||
}
|
||||
|
||||
async function requireCodeSession(sessionId: string): Promise<CodeSession> {
|
||||
const repo = container.resolve<ICodeSessionsRepo>('codeSessionsRepo');
|
||||
const session = await repo.get(sessionId);
|
||||
if (!session) {
|
||||
throw new Error(`Unknown code session: ${sessionId}`);
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
let codeSessionStatusWatcher: (() => void) | null = null;
|
||||
export async function startCodeSessionStatusWatcher(): Promise<void> {
|
||||
if (codeSessionStatusWatcher) {
|
||||
return;
|
||||
}
|
||||
const tracker = container.resolve<CodeSessionStatusTracker>('codeSessionStatusTracker');
|
||||
await tracker.start();
|
||||
codeSessionStatusWatcher = tracker.onTransition((sessionId, status) => {
|
||||
const windows = BrowserWindow.getAllWindows();
|
||||
for (const win of windows) {
|
||||
if (!win.isDestroyed() && win.webContents) {
|
||||
win.webContents.send('codeSession:status', { sessionId, status });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let runsWatcher: (() => void) | null = null;
|
||||
export async function startRunsWatcher(): Promise<void> {
|
||||
if (runsWatcher) {
|
||||
|
|
@ -444,6 +481,13 @@ export function setupIpcHandlers() {
|
|||
// Forward knowledge commit events to renderer for panel refresh
|
||||
versionHistory.onCommit(() => emitKnowledgeCommitEvent());
|
||||
|
||||
// Pre-warm the Gmail contact indices so the first compose-box keystroke is instant.
|
||||
// - warmContactIndex(): synchronous local-snapshot fallback (instant, narrow coverage).
|
||||
// - warmSentContacts(): kicks off a background Gmail API sync of the SENT label
|
||||
// for full historical coverage of people you've actually emailed.
|
||||
warmContactIndex();
|
||||
warmSentContacts();
|
||||
|
||||
registerIpcHandlers({
|
||||
'app:getVersions': async () => {
|
||||
// args is null for this channel (no request payload)
|
||||
|
|
@ -521,6 +565,22 @@ export function setupIpcHandlers() {
|
|||
saveMessageBodyHeight(args.threadId, args.messageId, args.height);
|
||||
return {};
|
||||
},
|
||||
'gmail:searchContacts': async (_event, args) => {
|
||||
const query = args?.query ?? '';
|
||||
const limit = args?.limit;
|
||||
const excludeEmails = args?.excludeEmails;
|
||||
|
||||
// Primary source: people you've actually sent mail to (Gmail SENT label,
|
||||
// cached + refreshed via the Gmail API). Fallback: local-snapshot index
|
||||
// — used only when the SENT index hasn't been populated yet (very first
|
||||
// launch, before the background sync finishes).
|
||||
const sent = await searchSentContacts(query, { limit, excludeEmails }).catch(() => []);
|
||||
if (sent.length > 0) {
|
||||
return { contacts: sent };
|
||||
}
|
||||
const fallback = await searchGmailContacts(query, { limit, excludeEmails });
|
||||
return { contacts: fallback };
|
||||
},
|
||||
'mcp:listTools': async (_event, args) => {
|
||||
return mcpCore.listTools(args.serverName, args.cursor);
|
||||
},
|
||||
|
|
@ -531,7 +591,7 @@ export function setupIpcHandlers() {
|
|||
return runsCore.createRun(args);
|
||||
},
|
||||
'runs:createMessage': async (_event, args) => {
|
||||
return { messageId: await runsCore.createMessage(args.runId, args.message, args.voiceInput, args.voiceOutput, args.searchEnabled, args.middlePaneContext, args.codeMode) };
|
||||
return { messageId: await runsCore.createMessage(args.runId, args.message, args.voiceInput, args.voiceOutput, args.searchEnabled, args.middlePaneContext, args.codeMode, args.codeCwd, args.codePolicy) };
|
||||
},
|
||||
'runs:authorizePermission': async (_event, args) => {
|
||||
await runsCore.authorizePermission(args.runId, args.authorization);
|
||||
|
|
@ -654,6 +714,104 @@ export function setupIpcHandlers() {
|
|||
'codeMode:checkAgentStatus': async () => {
|
||||
return await checkCodeModeAgentStatus();
|
||||
},
|
||||
'codeProject:add': async (_event, args) => {
|
||||
const repo = container.resolve<ICodeProjectsRepo>('codeProjectsRepo');
|
||||
const project = await repo.add(args.path);
|
||||
const git = await codeGit.repoInfo(project.path);
|
||||
return { project, git };
|
||||
},
|
||||
'codeProject:remove': async (_event, args) => {
|
||||
const repo = container.resolve<ICodeProjectsRepo>('codeProjectsRepo');
|
||||
await repo.remove(args.projectId);
|
||||
return { success: true };
|
||||
},
|
||||
'codeProject:list': async () => {
|
||||
const repo = container.resolve<ICodeProjectsRepo>('codeProjectsRepo');
|
||||
const projects = await repo.list();
|
||||
return {
|
||||
projects: await Promise.all(projects.map(async (project) => ({
|
||||
project,
|
||||
git: await codeGit.repoInfo(project.path),
|
||||
}))),
|
||||
};
|
||||
},
|
||||
'codeSession:create': async (_event, args) => {
|
||||
const service = container.resolve<CodeSessionService>('codeSessionService');
|
||||
const session = await service.create(args);
|
||||
return { session };
|
||||
},
|
||||
'codeSession:list': async () => {
|
||||
const repo = container.resolve<ICodeSessionsRepo>('codeSessionsRepo');
|
||||
const tracker = container.resolve<CodeSessionStatusTracker>('codeSessionStatusTracker');
|
||||
return { sessions: await repo.list(), statuses: tracker.getStatuses() };
|
||||
},
|
||||
'codeSession:update': async (_event, args) => {
|
||||
const service = container.resolve<CodeSessionService>('codeSessionService');
|
||||
return { session: await service.update(args.sessionId, args.patch) };
|
||||
},
|
||||
'codeSession:delete': async (_event, args) => {
|
||||
const service = container.resolve<CodeSessionService>('codeSessionService');
|
||||
disposeTerminal(args.sessionId);
|
||||
await service.delete(args.sessionId, {
|
||||
removeWorktree: args.removeWorktree,
|
||||
deleteBranch: args.deleteBranch,
|
||||
});
|
||||
return { success: true };
|
||||
},
|
||||
'codeSession:sendMessage': async (_event, args) => {
|
||||
const service = container.resolve<CodeSessionService>('codeSessionService');
|
||||
// Intentionally not awaited: the turn can run for minutes and streams over
|
||||
// runs:events. sendMessage validates synchronously enough that busy/unknown
|
||||
// errors are reported via the run's error events instead.
|
||||
const resultPromise = service.sendMessage(args.sessionId, args.text);
|
||||
// Surface immediate rejections (busy session, unknown id) to the caller.
|
||||
const result = await Promise.race([
|
||||
resultPromise,
|
||||
new Promise<{ accepted: true }>((resolve) => setTimeout(() => resolve({ accepted: true }), 300)),
|
||||
]);
|
||||
resultPromise.catch((err) => console.error('codeSession:sendMessage failed', err));
|
||||
return result;
|
||||
},
|
||||
'codeSession:stop': async (_event, args) => {
|
||||
const service = container.resolve<CodeSessionService>('codeSessionService');
|
||||
await service.stop(args.sessionId);
|
||||
return { success: true };
|
||||
},
|
||||
'codeSession:gitStatus': async (_event, args) => {
|
||||
const session = await requireCodeSession(args.sessionId);
|
||||
const info = await codeGit.repoInfo(session.cwd);
|
||||
if (!info.isGitRepo) {
|
||||
return { isRepo: false, branch: null, hasCommits: false, files: [] };
|
||||
}
|
||||
const files = await codeGit.status(session.cwd);
|
||||
return { isRepo: true, branch: info.branch, hasCommits: info.hasCommits, files };
|
||||
},
|
||||
'codeSession:fileDiff': async (_event, args) => {
|
||||
const session = await requireCodeSession(args.sessionId);
|
||||
return codeGit.fileDiff(session.cwd, args.path);
|
||||
},
|
||||
'codeSession:readdir': async (_event, args) => {
|
||||
const session = await requireCodeSession(args.sessionId);
|
||||
return { entries: await readProjectDir(session.cwd, args.relPath) };
|
||||
},
|
||||
'codeSession:readFile': async (_event, args) => {
|
||||
const session = await requireCodeSession(args.sessionId);
|
||||
return readProjectFile(session.cwd, args.relPath);
|
||||
},
|
||||
'codeSession:mergeBack': async (_event, args) => {
|
||||
const service = container.resolve<CodeSessionService>('codeSessionService');
|
||||
return service.mergeBack(args.sessionId);
|
||||
},
|
||||
'codeSession:cleanupWorktree': async (_event, args) => {
|
||||
const service = container.resolve<CodeSessionService>('codeSessionService');
|
||||
try {
|
||||
await service.cleanupWorktree(args.sessionId, args.deleteBranch);
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to clean up worktree';
|
||||
return { success: false, error: message };
|
||||
}
|
||||
},
|
||||
'granola:setConfig': async (_event, args) => {
|
||||
const repo = container.resolve<IGranolaConfigRepo>('granolaConfigRepo');
|
||||
await repo.setConfig({ enabled: args.enabled });
|
||||
|
|
@ -804,6 +962,30 @@ export function setupIpcHandlers() {
|
|||
}
|
||||
return { path: result.filePaths[0] ?? null };
|
||||
},
|
||||
'terminal:ensure': async (_event, args) => {
|
||||
return ensureTerminal(args.id, args.cwd, args.cols, args.rows);
|
||||
},
|
||||
'terminal:input': async (_event, args) => {
|
||||
writeTerminal(args.id, args.data);
|
||||
return { success: true };
|
||||
},
|
||||
'terminal:resize': async (_event, args) => {
|
||||
resizeTerminal(args.id, args.cols, args.rows);
|
||||
return { success: true };
|
||||
},
|
||||
'terminal:dispose': async (_event, args) => {
|
||||
disposeTerminal(args.id);
|
||||
return { success: true };
|
||||
},
|
||||
'dialog:openFiles': async (event, args) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender);
|
||||
const result = await dialog.showOpenDialog(win!, {
|
||||
title: args.title ?? 'Attach files',
|
||||
...(args.defaultPath ? { defaultPath: resolveShellPath(args.defaultPath) } : {}),
|
||||
properties: ['openFile', 'multiSelections'],
|
||||
});
|
||||
return { paths: result.canceled ? [] : result.filePaths };
|
||||
},
|
||||
// Knowledge version history handlers
|
||||
'knowledge:history': async (_event, args) => {
|
||||
const commits = await versionHistory.getFileHistory(args.path);
|
||||
|
|
@ -919,6 +1101,24 @@ export function setupIpcHandlers() {
|
|||
'voice:synthesize': async (_event, args) => {
|
||||
return voice.synthesizeSpeech(args.text);
|
||||
},
|
||||
'voice:ensureMicAccess': async () => {
|
||||
if (process.platform !== 'darwin') return { granted: true };
|
||||
const status = systemPreferences.getMediaAccessStatus('microphone');
|
||||
console.log('[voice] Microphone permission status:', status);
|
||||
if (status === 'granted') return { granted: true };
|
||||
// 'not-determined' shows the native TCC prompt and resolves once the
|
||||
// user responds; 'denied'/'restricted' resolve false without prompting.
|
||||
// Awaiting this here means the triggering mic click proceeds to
|
||||
// getUserMedia only after permission is settled — fixing the first
|
||||
// click silently failing while the prompt was still up.
|
||||
try {
|
||||
const granted = await systemPreferences.askForMediaAccess('microphone');
|
||||
console.log('[voice] Microphone permission after prompt:', granted);
|
||||
return { granted };
|
||||
} catch {
|
||||
return { granted: false };
|
||||
}
|
||||
},
|
||||
// Live-note handlers
|
||||
'live-note:run': async (_event, args) => {
|
||||
const result = await runLiveNoteAgent(args.filePath, 'manual', args.context);
|
||||
|
|
@ -1052,6 +1252,13 @@ export function setupIpcHandlers() {
|
|||
'billing:getInfo': async () => {
|
||||
return await getBillingInfo();
|
||||
},
|
||||
'notifications:getSettings': async () => {
|
||||
return loadNotificationSettings();
|
||||
},
|
||||
'notifications:setSettings': async (_event, args) => {
|
||||
saveNotificationSettings(args);
|
||||
return { success: true };
|
||||
},
|
||||
// Embedded browser handlers (WebContentsView + navigation)
|
||||
...browserIpcHandlers,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import path from "node:path";
|
|||
import {
|
||||
setupIpcHandlers,
|
||||
startRunsWatcher,
|
||||
startCodeSessionStatusWatcher,
|
||||
startServicesWatcher,
|
||||
startLiveNoteAgentWatcher,
|
||||
startBackgroundTaskAgentWatcher,
|
||||
|
|
@ -11,6 +12,7 @@ import {
|
|||
stopServicesWatcher,
|
||||
stopWorkspaceWatcher
|
||||
} from "./ipc.js";
|
||||
import { disposeAllTerminals } from "./terminal.js";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { dirname } from "node:path";
|
||||
import { updateElectronApp, UpdateSourceType } from "update-electron-app";
|
||||
|
|
@ -253,14 +255,34 @@ function createWindow() {
|
|||
return { action: "deny" };
|
||||
});
|
||||
|
||||
// Handle navigation to external URLs (e.g., clicking a link without target="_blank")
|
||||
win.webContents.on("will-navigate", (event, url) => {
|
||||
// Handle navigation to external URLs (e.g., clicking a link without target="_blank").
|
||||
// Returns true when the URL was external and routed to the system browser.
|
||||
const routeExternalNavigation = (url: string): boolean => {
|
||||
const isInternal =
|
||||
url.startsWith("app://") || url.startsWith("http://localhost:5173");
|
||||
if (!isInternal) {
|
||||
event.preventDefault();
|
||||
shell.openExternal(url);
|
||||
}
|
||||
if (isInternal) return false;
|
||||
shell.openExternal(url);
|
||||
return true;
|
||||
};
|
||||
|
||||
win.webContents.on("will-navigate", (event, url) => {
|
||||
if (routeExternalNavigation(url)) event.preventDefault();
|
||||
});
|
||||
|
||||
// Subframe navigations (e.g. links clicked inside the sandboxed iframe that
|
||||
// renders a background-task / workspace `index.html`) fire `will-frame-navigate`,
|
||||
// not `will-navigate`. Route their external links to the system browser too,
|
||||
// so HTML reports behave like the markdown viewer. Main-frame navigations are
|
||||
// already handled by `will-navigate` above — skip them here to avoid double-open.
|
||||
//
|
||||
// Scope this to our own HTML viewer frames (identified by their app://workspace
|
||||
// document origin). Third-party note embeds (YouTube, Figma, Twitter via the
|
||||
// embed/iframe blocks) load from their own origins — leave their internal
|
||||
// navigation untouched so the embeds keep working.
|
||||
win.webContents.on("will-frame-navigate", (event) => {
|
||||
if (event.isMainFrame) return;
|
||||
if (!event.frame?.url.startsWith("app://workspace/")) return;
|
||||
if (routeExternalNavigation(event.url)) event.preventDefault();
|
||||
});
|
||||
|
||||
// Attach the embedded browser pane manager to this window.
|
||||
|
|
@ -332,6 +354,9 @@ app.whenReady().then(async () => {
|
|||
// start runs watcher
|
||||
startRunsWatcher();
|
||||
|
||||
// start code-session status tracker (derives working/needs-you/idle + notifications)
|
||||
startCodeSessionStatusWatcher();
|
||||
|
||||
// start services watcher
|
||||
startServicesWatcher();
|
||||
|
||||
|
|
@ -424,6 +449,8 @@ app.on("before-quit", () => {
|
|||
} catch {
|
||||
// nothing live to dispose
|
||||
}
|
||||
// Kill embedded terminal shells.
|
||||
disposeAllTerminals();
|
||||
shutdownLocalSites().catch((error) => {
|
||||
console.error('[LocalSites] Failed to shut down cleanly:', error);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -15,7 +15,15 @@ export class ElectronNotificationService implements INotificationService {
|
|||
return Notification.isSupported();
|
||||
}
|
||||
|
||||
notify({ title = "Rowboat", message, link, actionLabel, secondaryActions }: NotifyInput): void {
|
||||
notify({ title = "Rowboat", message, link, actionLabel, secondaryActions, onlyWhenBackground }: NotifyInput): void {
|
||||
// Ambient notifications are suppressed while the app is in the
|
||||
// foreground — the user is already looking at it. A window counts as
|
||||
// foreground only if it's actually focused (minimized / other-space
|
||||
// windows are not), so this correctly treats those as background.
|
||||
if (onlyWhenBackground && BrowserWindow.getAllWindows().some((w) => w.isFocused())) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Build the actions array AND a parallel index → link map.
|
||||
// macOS shows actions[0] inline (Banner) or all of them (Alert);
|
||||
// additional ones live behind the chevron menu.
|
||||
|
|
|
|||
126
apps/x/apps/main/src/terminal.ts
Normal file
126
apps/x/apps/main/src/terminal.ts
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
import { BrowserWindow } from 'electron';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
// node-pty is a NATIVE module: it stays external to the esbuild bundle and is
|
||||
// shipped alongside it in .package/node_modules (see bundle.mjs).
|
||||
import * as pty from 'node-pty';
|
||||
|
||||
// One PTY per coding session, kept alive while the app runs so the terminal
|
||||
// survives pane collapses and session switches. The renderer view re-attaches
|
||||
// via `terminal:ensure`, which replays the recent backlog.
|
||||
|
||||
const BACKLOG_LIMIT = 400_000; // chars (~400KB) of scrollback replay
|
||||
|
||||
interface TerminalEntry {
|
||||
proc: pty.IPty;
|
||||
cwd: string;
|
||||
backlog: string;
|
||||
running: boolean;
|
||||
}
|
||||
|
||||
const terminals = new Map<string, TerminalEntry>();
|
||||
|
||||
function broadcast(channel: 'terminal:data' | 'terminal:exit', payload: unknown): void {
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
if (!win.isDestroyed() && win.webContents) {
|
||||
win.webContents.send(channel, payload);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// pnpm extracts node-pty's prebuilt macOS spawn-helper without its executable
|
||||
// bit, which makes every spawn fail with "posix_spawnp failed". Repair it once.
|
||||
let helperFixed = false;
|
||||
function ensureSpawnHelperExecutable(): void {
|
||||
if (helperFixed || process.platform === 'win32') return;
|
||||
helperFixed = true;
|
||||
try {
|
||||
const pkgDir = path.dirname(require.resolve('node-pty/package.json'));
|
||||
const helper = path.join(pkgDir, 'prebuilds', `${process.platform}-${process.arch}`, 'spawn-helper');
|
||||
if (fs.existsSync(helper)) {
|
||||
fs.chmodSync(helper, 0o755);
|
||||
}
|
||||
} catch {
|
||||
// best effort — spawn() will surface a real error if this mattered
|
||||
}
|
||||
}
|
||||
|
||||
function defaultShell(): { file: string; args: string[] } {
|
||||
if (process.platform === 'win32') {
|
||||
return { file: 'powershell.exe', args: [] };
|
||||
}
|
||||
// Login shell so the user's PATH/aliases match their normal terminal.
|
||||
return { file: process.env.SHELL || '/bin/zsh', args: ['-l'] };
|
||||
}
|
||||
|
||||
function spawnEntry(id: string, cwd: string, cols: number, rows: number): TerminalEntry {
|
||||
ensureSpawnHelperExecutable();
|
||||
const { file, args } = defaultShell();
|
||||
const proc = pty.spawn(file, args, {
|
||||
name: 'xterm-256color',
|
||||
cwd,
|
||||
cols,
|
||||
rows,
|
||||
env: { ...process.env, TERM_PROGRAM: 'rowboat' } as Record<string, string>,
|
||||
});
|
||||
const entry: TerminalEntry = { proc, cwd, backlog: '', running: true };
|
||||
proc.onData((data) => {
|
||||
entry.backlog = (entry.backlog + data).slice(-BACKLOG_LIMIT);
|
||||
broadcast('terminal:data', { id, data });
|
||||
});
|
||||
proc.onExit(({ exitCode }) => {
|
||||
entry.running = false;
|
||||
broadcast('terminal:exit', { id, exitCode });
|
||||
});
|
||||
terminals.set(id, entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
// Create-or-attach. A cwd change (e.g. the session's worktree was removed) or
|
||||
// an exited shell gets a fresh PTY; otherwise the live one is reused and the
|
||||
// caller repaints from the backlog.
|
||||
export function ensureTerminal(id: string, cwd: string, cols: number, rows: number): { backlog: string; running: boolean } {
|
||||
const existing = terminals.get(id);
|
||||
if (existing && existing.running && existing.cwd === cwd) {
|
||||
existing.proc.resize(cols, rows);
|
||||
return { backlog: existing.backlog, running: true };
|
||||
}
|
||||
if (existing) {
|
||||
disposeTerminal(id);
|
||||
}
|
||||
const fallbackCwd = fs.existsSync(cwd) ? cwd : os.homedir();
|
||||
const entry = spawnEntry(id, fallbackCwd, cols, rows);
|
||||
return { backlog: entry.backlog, running: entry.running };
|
||||
}
|
||||
|
||||
export function writeTerminal(id: string, data: string): void {
|
||||
const entry = terminals.get(id);
|
||||
if (entry?.running) entry.proc.write(data);
|
||||
}
|
||||
|
||||
export function resizeTerminal(id: string, cols: number, rows: number): void {
|
||||
const entry = terminals.get(id);
|
||||
if (entry?.running) {
|
||||
try {
|
||||
entry.proc.resize(cols, rows);
|
||||
} catch {
|
||||
// resizing a dying pty throws — harmless
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function disposeTerminal(id: string): void {
|
||||
const entry = terminals.get(id);
|
||||
if (!entry) return;
|
||||
terminals.delete(id);
|
||||
try {
|
||||
entry.proc.kill();
|
||||
} catch {
|
||||
// already gone
|
||||
}
|
||||
}
|
||||
|
||||
export function disposeAllTerminals(): void {
|
||||
for (const id of [...terminals.keys()]) disposeTerminal(id);
|
||||
}
|
||||
|
|
@ -9,7 +9,13 @@
|
|||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.12.3",
|
||||
"@codemirror/language-data": "^6.5.2",
|
||||
"@codemirror/merge": "^6.12.2",
|
||||
"@codemirror/state": "^6.6.0",
|
||||
"@codemirror/view": "^6.43.1",
|
||||
"@eigenpal/docx-editor-react": "^1.0.3",
|
||||
"@lezer/highlight": "^1.2.3",
|
||||
"@radix-ui/react-avatar": "^1.1.11",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-context-menu": "^2.2.16",
|
||||
|
|
@ -38,10 +44,13 @@
|
|||
"@tiptap/starter-kit": "3.22.4",
|
||||
"@x/preload": "workspace:*",
|
||||
"@x/shared": "workspace:*",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"ai": "^5.0.117",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"codemirror": "^6.0.2",
|
||||
"lucide-react": "^0.562.0",
|
||||
"mermaid": "^11.14.0",
|
||||
"motion": "^12.23.26",
|
||||
|
|
|
|||
|
|
@ -800,6 +800,108 @@
|
|||
gap: 4px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.gmail-recipient-suggestions {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
left: 0;
|
||||
z-index: 30;
|
||||
margin: 0;
|
||||
padding: 6px;
|
||||
list-style: none;
|
||||
width: max-content;
|
||||
min-width: 280px;
|
||||
max-width: min(440px, 100%);
|
||||
background: var(--gm-bg-elevated, #1e1e1e);
|
||||
border: 1px solid var(--gm-border);
|
||||
border-radius: 10px;
|
||||
box-shadow:
|
||||
0 1px 2px rgba(0, 0, 0, 0.18),
|
||||
0 12px 32px rgba(0, 0, 0, 0.36);
|
||||
max-height: 296px;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
transform-origin: top left;
|
||||
animation: gmail-recipient-suggestions-in 110ms cubic-bezier(0.2, 0.7, 0.2, 1);
|
||||
}
|
||||
|
||||
@keyframes gmail-recipient-suggestions-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-2px) scale(0.985);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.gmail-recipient-suggestion {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
color: var(--gm-text);
|
||||
cursor: pointer;
|
||||
transition: background-color 80ms linear;
|
||||
}
|
||||
|
||||
.gmail-recipient-suggestion:hover {
|
||||
background: var(--gm-bg-pill-hover);
|
||||
}
|
||||
|
||||
.gmail-recipient-suggestion.is-active {
|
||||
background: rgba(99, 142, 255, 0.18);
|
||||
}
|
||||
|
||||
.gmail-recipient-suggestion-avatar {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 50%;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.2px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.gmail-recipient-suggestion-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.gmail-recipient-suggestion-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.gmail-recipient-suggestion-email {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 11.5px;
|
||||
color: var(--gm-text-muted);
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.gmail-recipient-suggestion-match {
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font-weight: 700;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.gmail-recipient-chip {
|
||||
|
|
|
|||
|
|
@ -34,6 +34,9 @@ import { KnowledgeView } from '@/components/knowledge-view';
|
|||
import { ChatHistoryView } from '@/components/chat-history-view';
|
||||
import { HomeView } from '@/components/home-view';
|
||||
import { MeetingsView } from '@/components/meetings-view';
|
||||
import { CodeView, type ActiveCodeSession } from '@/components/code/code-view';
|
||||
import { CodeChat } from '@/components/code/code-chat';
|
||||
import { ResizableRightPane } from '@/components/code/resizable-right-pane';
|
||||
import { SidebarSectionProvider } from '@/contexts/sidebar-context';
|
||||
import {
|
||||
Conversation,
|
||||
|
|
@ -199,6 +202,7 @@ const KNOWLEDGE_VIEW_TAB_PATH = '__rowboat_knowledge_view__'
|
|||
const CHAT_HISTORY_TAB_PATH = '__rowboat_chat_history__'
|
||||
const HOME_TAB_PATH = '__rowboat_home__'
|
||||
const BASES_DEFAULT_TAB_PATH = '__rowboat_bases_default__'
|
||||
const CODE_TAB_PATH = '__rowboat_code__'
|
||||
|
||||
const clampNumber = (value: number, min: number, max: number) =>
|
||||
Math.min(max, Math.max(min, value))
|
||||
|
|
@ -336,6 +340,7 @@ const isKnowledgeViewTabPath = (path: string) => path === KNOWLEDGE_VIEW_TAB_PAT
|
|||
const isChatHistoryTabPath = (path: string) => path === CHAT_HISTORY_TAB_PATH
|
||||
const isHomeTabPath = (path: string) => path === HOME_TAB_PATH
|
||||
const isBaseFilePath = (path: string) => path.endsWith('.base') || path === BASES_DEFAULT_TAB_PATH
|
||||
const isCodeTabPath = (path: string) => path === CODE_TAB_PATH
|
||||
|
||||
const getSuggestedTopicTargetFolder = (category?: string) => {
|
||||
const normalized = category?.trim().toLowerCase()
|
||||
|
|
@ -589,6 +594,7 @@ type ViewState =
|
|||
| { type: 'knowledge-view'; folderPath?: string }
|
||||
| { type: 'chat-history' }
|
||||
| { type: 'home' }
|
||||
| { type: 'code' }
|
||||
|
||||
function viewStatesEqual(a: ViewState, b: ViewState): boolean {
|
||||
if (a.type !== b.type) return false
|
||||
|
|
@ -652,6 +658,8 @@ function parseDeepLink(input: string): ViewState | null {
|
|||
return { type: 'chat-history' }
|
||||
case 'home':
|
||||
return { type: 'home' }
|
||||
case 'code':
|
||||
return { type: 'code' }
|
||||
default:
|
||||
return null
|
||||
}
|
||||
|
|
@ -1034,7 +1042,7 @@ function App() {
|
|||
}, [])
|
||||
|
||||
// Runs history state
|
||||
type RunListItem = { id: string; title?: string; createdAt: string; agentId: string }
|
||||
type RunListItem = { id: string; title?: string; createdAt: string; agentId: string; useCase?: string }
|
||||
const [runs, setRuns] = useState<RunListItem[]>([])
|
||||
|
||||
// Chat tab state
|
||||
|
|
@ -1159,6 +1167,23 @@ function App() {
|
|||
const [activeFileTabId, setActiveFileTabId] = useState<string | null>('home-tab')
|
||||
const activeFileTabIdRef = useRef(activeFileTabId)
|
||||
activeFileTabIdRef.current = activeFileTabId
|
||||
// The Code section is tab-derived (no boolean to keep in sync with the other
|
||||
// section flags): it is open exactly while its sentinel tab is active.
|
||||
const isCodeOpen = React.useMemo(() => {
|
||||
const activeTab = fileTabs.find((tab) => tab.id === activeFileTabId)
|
||||
return activeTab ? isCodeTabPath(activeTab.path) : false
|
||||
}, [fileTabs, activeFileTabId])
|
||||
// The code session that owns the right-hand chat pane: rowboat-mode sessions
|
||||
// bind the assistant chat to their run; direct-mode sessions swap the pane
|
||||
// for the direct-drive chat.
|
||||
const [activeCodeSession, setActiveCodeSession] = useState<ActiveCodeSession | null>(null)
|
||||
// A file the code chat asked to review — consumed by the workspace pane.
|
||||
const [codeDiffPath, setCodeDiffPath] = useState<string | null>(null)
|
||||
const boundCodeSessionRef = useRef<string | null>(null)
|
||||
// Composer locks for runs that are code sessions: the session's cwd + agent
|
||||
// are frozen in the chat input (the backend pins them server-side anyway).
|
||||
// Kept after the Code view unmounts — the chat tab stays bound to the run.
|
||||
const [codeSessionLocks, setCodeSessionLocks] = useState<Record<string, { cwd: string; agent: 'claude' | 'codex' }>>({})
|
||||
const [editorSessionByTabId, setEditorSessionByTabId] = useState<Record<string, number>>({})
|
||||
const fileHistoryHandlersRef = useRef<Map<string, MarkdownHistoryHandlers>>(new Map())
|
||||
const fileTabIdCounterRef = useRef(0)
|
||||
|
|
@ -1175,6 +1200,7 @@ function App() {
|
|||
if (isKnowledgeViewTabPath(tab.path)) return 'Notes'
|
||||
if (isChatHistoryTabPath(tab.path)) return 'Chat history'
|
||||
if (isHomeTabPath(tab.path)) return 'Home'
|
||||
if (isCodeTabPath(tab.path)) return 'Code'
|
||||
if (tab.path === BASES_DEFAULT_TAB_PATH) return 'Bases'
|
||||
if (tab.path.endsWith('.base')) return tab.path.split('/').pop()?.replace(/\.base$/i, '') || 'Base'
|
||||
return tab.path.split('/').pop()?.replace(/\.md$/i, '') || tab.path
|
||||
|
|
@ -1807,8 +1833,8 @@ function App() {
|
|||
cursor = result.nextCursor
|
||||
} while (cursor)
|
||||
|
||||
// Filter for copilot runs only
|
||||
const copilotRuns = allRuns.filter((run: RunListItem) => run.agentId === 'copilot')
|
||||
// Filter for copilot chats only (Code-section sessions live in the Code view)
|
||||
const copilotRuns = allRuns.filter((run: RunListItem) => run.agentId === 'copilot' && run.useCase !== 'code_session')
|
||||
setRuns(copilotRuns)
|
||||
} catch (err) {
|
||||
console.error('Failed to load runs:', err)
|
||||
|
|
@ -2075,6 +2101,15 @@ function App() {
|
|||
setConversation(items)
|
||||
setRunId(id)
|
||||
setMessage('')
|
||||
// Reconcile composer state with THIS run. Loading a run while another one
|
||||
// is mid-turn (e.g. binding a code session steals the single chat tab)
|
||||
// must not leave isProcessing/isStopping pointing at the old run — that
|
||||
// wedges the composer: stop targets the new run (a no-op) while the old
|
||||
// run's processing-end arrives flagged as non-active and clears nothing.
|
||||
setIsProcessing(processingRunIdsRef.current.has(id))
|
||||
setIsStopping(false)
|
||||
setStopClickedAt(null)
|
||||
setCurrentAssistantMessage(streamingBuffersRef.current.get(id)?.assistant ?? '')
|
||||
setPendingPermissionRequests(pendingPerms)
|
||||
setPendingAskHumanRequests(pendingAsks)
|
||||
setAllPermissionRequests(allPermissionRequests)
|
||||
|
|
@ -2145,6 +2180,11 @@ function App() {
|
|||
break
|
||||
|
||||
case 'start':
|
||||
// Run creation alone isn't a turn. Code-session runs are created when
|
||||
// the session is (no message follows until the user sends one), so
|
||||
// marking them processing here would never be cleared — and wedge the
|
||||
// composer (Stop shown, send blocked) once the session binds a chat tab.
|
||||
if (event.useCase === 'code_session') return
|
||||
setProcessingRunIds(prev => {
|
||||
if (prev.has(event.runId)) return prev
|
||||
const next = new Set(prev)
|
||||
|
|
@ -2878,6 +2918,38 @@ function App() {
|
|||
}
|
||||
}, [chatTabs, activeChatTabId, applyChatTab, loadRun, restoreChatTabState, saveChatScrollForTab])
|
||||
|
||||
// A code session was selected (or changed mode/status) in the Code view.
|
||||
// Rowboat-mode sessions take over the assistant chat pane by binding their
|
||||
// run to a chat tab — the conversation IS the assistant chat, no copy.
|
||||
// Direct-mode sessions render their own pane instead (see right-pane JSX).
|
||||
const handleCodeSessionSelected = useCallback((active: ActiveCodeSession | null) => {
|
||||
setActiveCodeSession(active)
|
||||
if (active) {
|
||||
const { id, cwd, agent } = active.session
|
||||
setCodeSessionLocks((prev) => (
|
||||
prev[id]?.cwd === cwd && prev[id]?.agent === agent
|
||||
? prev
|
||||
: { ...prev, [id]: { cwd, agent } }
|
||||
))
|
||||
}
|
||||
const rowboatSessionId = active && active.session.mode === 'rowboat' ? active.session.id : null
|
||||
if (!rowboatSessionId) {
|
||||
boundCodeSessionRef.current = null
|
||||
return
|
||||
}
|
||||
if (boundCodeSessionRef.current === rowboatSessionId) return
|
||||
boundCodeSessionRef.current = rowboatSessionId
|
||||
const existingTab = chatTabsRef.current.find((t) => t.runId === rowboatSessionId)
|
||||
if (existingTab) {
|
||||
switchChatTab(existingTab.id)
|
||||
return
|
||||
}
|
||||
setChatTabs((prev) => prev.map((t) => (
|
||||
t.id === activeChatTabIdRef.current ? { ...t, runId: rowboatSessionId } : t
|
||||
)))
|
||||
loadRun(rowboatSessionId)
|
||||
}, [switchChatTab, loadRun])
|
||||
|
||||
const closeChatTab = useCallback((tabId: string) => {
|
||||
if (chatTabs.length <= 1) return
|
||||
const idx = chatTabs.findIndex(t => t.id === tabId)
|
||||
|
|
@ -3147,6 +3219,14 @@ function App() {
|
|||
setIsHomeOpen(true)
|
||||
return
|
||||
}
|
||||
if (isCodeTabPath(tab.path)) {
|
||||
// isCodeOpen itself is derived from the active tab — just clear the rest.
|
||||
setSelectedPath(null)
|
||||
setIsGraphOpen(false)
|
||||
setIsSuggestedTopicsOpen(false)
|
||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false)
|
||||
return
|
||||
}
|
||||
setIsGraphOpen(false)
|
||||
setIsSuggestedTopicsOpen(false)
|
||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false)
|
||||
|
|
@ -3155,7 +3235,7 @@ function App() {
|
|||
|
||||
const closeFileTab = useCallback((tabId: string) => {
|
||||
const closingTab = fileTabs.find(t => t.id === tabId)
|
||||
if (closingTab && !isGraphTabPath(closingTab.path) && !isSuggestedTopicsTabPath(closingTab.path) && !isLiveNotesTabPath(closingTab.path) && !isBgTasksTabPath(closingTab.path) && !isEmailTabPath(closingTab.path) && !isWorkspaceTabPath(closingTab.path) && !isKnowledgeViewTabPath(closingTab.path) && !isChatHistoryTabPath(closingTab.path) && !isHomeTabPath(closingTab.path) && !isBaseFilePath(closingTab.path)) {
|
||||
if (closingTab && !isGraphTabPath(closingTab.path) && !isSuggestedTopicsTabPath(closingTab.path) && !isLiveNotesTabPath(closingTab.path) && !isBgTasksTabPath(closingTab.path) && !isEmailTabPath(closingTab.path) && !isWorkspaceTabPath(closingTab.path) && !isKnowledgeViewTabPath(closingTab.path) && !isChatHistoryTabPath(closingTab.path) && !isHomeTabPath(closingTab.path) && !isCodeTabPath(closingTab.path) && !isBaseFilePath(closingTab.path)) {
|
||||
removeEditorCacheForPath(closingTab.path)
|
||||
initialContentByPathRef.current.delete(closingTab.path)
|
||||
untitledRenameReadyPathsRef.current.delete(closingTab.path)
|
||||
|
|
@ -3548,10 +3628,11 @@ function App() {
|
|||
if (isKnowledgeViewOpen) return { type: 'knowledge-view', folderPath: knowledgeViewFolderPath ?? undefined }
|
||||
if (isChatHistoryOpen) return { type: 'chat-history' }
|
||||
if (isHomeOpen) return { type: 'home' }
|
||||
if (isCodeOpen) return { type: 'code' }
|
||||
if (selectedPath) return { type: 'file', path: selectedPath }
|
||||
if (isGraphOpen) return { type: 'graph' }
|
||||
return { type: 'chat', runId }
|
||||
}, [selectedBackgroundTask, isEmailOpen, isMeetingsOpen, isLiveNotesOpen, isBgTasksOpen, isSuggestedTopicsOpen, selectedPath, isGraphOpen, isWorkspaceOpen, isKnowledgeViewOpen, knowledgeViewFolderPath, isChatHistoryOpen, isHomeOpen, workspaceInitialPath, runId])
|
||||
}, [selectedBackgroundTask, isEmailOpen, isMeetingsOpen, isLiveNotesOpen, isBgTasksOpen, isSuggestedTopicsOpen, selectedPath, isGraphOpen, isWorkspaceOpen, isKnowledgeViewOpen, knowledgeViewFolderPath, isChatHistoryOpen, isHomeOpen, isCodeOpen, workspaceInitialPath, runId])
|
||||
|
||||
const appendUnique = useCallback((stack: ViewState[], entry: ViewState) => {
|
||||
const last = stack[stack.length - 1]
|
||||
|
|
@ -3696,6 +3777,17 @@ function App() {
|
|||
setActiveFileTabId(id)
|
||||
}, [fileTabs])
|
||||
|
||||
const ensureCodeFileTab = useCallback(() => {
|
||||
const existing = fileTabs.find((tab) => isCodeTabPath(tab.path))
|
||||
if (existing) {
|
||||
setActiveFileTabId(existing.id)
|
||||
return
|
||||
}
|
||||
const id = newFileTabId()
|
||||
setFileTabs((prev) => [...prev, { id, path: CODE_TAB_PATH }])
|
||||
setActiveFileTabId(id)
|
||||
}, [fileTabs])
|
||||
|
||||
const openEmailView = useCallback((threadId?: string) => {
|
||||
setSelectedPath(null)
|
||||
setIsGraphOpen(false)
|
||||
|
|
@ -3751,6 +3843,18 @@ function App() {
|
|||
ensureMeetingsFileTab()
|
||||
}, [ensureMeetingsFileTab])
|
||||
|
||||
const openCodeView = useCallback(() => {
|
||||
setSelectedPath(null)
|
||||
setIsGraphOpen(false)
|
||||
setIsBrowserOpen(false)
|
||||
setIsSuggestedTopicsOpen(false)
|
||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false)
|
||||
setSelectedBackgroundTask(null)
|
||||
setExpandedFrom(null)
|
||||
setIsRightPaneMaximized(false)
|
||||
ensureCodeFileTab()
|
||||
}, [ensureCodeFileTab])
|
||||
|
||||
const applyViewState = useCallback(async (view: ViewState) => {
|
||||
switch (view.type) {
|
||||
case 'file':
|
||||
|
|
@ -3931,6 +4035,17 @@ function App() {
|
|||
setIsHomeOpen(true)
|
||||
ensureHomeFileTab()
|
||||
return
|
||||
case 'code':
|
||||
setSelectedPath(null)
|
||||
setIsGraphOpen(false)
|
||||
setIsBrowserOpen(false)
|
||||
setExpandedFrom(null)
|
||||
setIsRightPaneMaximized(false)
|
||||
setSelectedBackgroundTask(null)
|
||||
setIsSuggestedTopicsOpen(false)
|
||||
setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false)
|
||||
ensureCodeFileTab()
|
||||
return
|
||||
case 'chat':
|
||||
setSelectedPath(null)
|
||||
setIsGraphOpen(false)
|
||||
|
|
@ -3959,7 +4074,7 @@ function App() {
|
|||
}
|
||||
return
|
||||
}
|
||||
}, [ensureEmailFileTab, ensureMeetingsFileTab, ensureLiveNotesFileTab, ensureFileTabForPath, ensureGraphFileTab, ensureSuggestedTopicsFileTab, ensureWorkspaceFileTab, ensureKnowledgeViewFileTab, ensureChatHistoryFileTab, ensureHomeFileTab, handleNewChat, isRightPaneMaximized, loadRun])
|
||||
}, [ensureEmailFileTab, ensureMeetingsFileTab, ensureLiveNotesFileTab, ensureFileTabForPath, ensureGraphFileTab, ensureSuggestedTopicsFileTab, ensureWorkspaceFileTab, ensureKnowledgeViewFileTab, ensureChatHistoryFileTab, ensureHomeFileTab, ensureCodeFileTab, handleNewChat, isRightPaneMaximized, loadRun])
|
||||
|
||||
const navigateToView = useCallback(async (nextView: ViewState) => {
|
||||
const current = currentViewState
|
||||
|
|
@ -4294,7 +4409,7 @@ function App() {
|
|||
}, [])
|
||||
|
||||
// Keyboard shortcut: Ctrl+L to toggle main chat view
|
||||
const isFullScreenChat = !selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !isKnowledgeViewOpen && !isChatHistoryOpen && !isHomeOpen && !selectedBackgroundTask && !isBrowserOpen
|
||||
const isFullScreenChat = !selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !isKnowledgeViewOpen && !isChatHistoryOpen && !isHomeOpen && !isCodeOpen && !selectedBackgroundTask && !isBrowserOpen
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'l') {
|
||||
|
|
@ -5300,7 +5415,7 @@ function App() {
|
|||
const selectedTask = selectedBackgroundTask
|
||||
? backgroundTasks.find(t => t.name === selectedBackgroundTask)
|
||||
: null
|
||||
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 || isCodeOpen || isBrowserOpen)
|
||||
const isRightPaneOnlyMode = isRightPaneContext && isChatSidebarOpen && isRightPaneMaximized
|
||||
const shouldCollapseLeftPane = isRightPaneOnlyMode
|
||||
const nonChatPaneStyle = React.useMemo<React.CSSProperties>(() => {
|
||||
|
|
@ -5369,12 +5484,14 @@ function App() {
|
|||
isHomeOpen ? 'home'
|
||||
: isEmailOpen ? 'email'
|
||||
: isMeetingsOpen ? 'meetings'
|
||||
: isCodeOpen ? 'code'
|
||||
: (isKnowledgeViewOpen || isGraphOpen || (selectedPath != null && selectedPath.startsWith('knowledge/'))) ? 'knowledge'
|
||||
: isBgTasksOpen ? 'agents'
|
||||
: isWorkspaceOpen ? 'workspaces'
|
||||
: null
|
||||
}
|
||||
onOpenMeetings={openMeetingsView}
|
||||
onOpenCode={openCodeView}
|
||||
onOpenBgTasks={() => { setBgTaskInitialSlug(null); setBgTaskSlugVersion((v) => v + 1); openBgTasksView() }}
|
||||
onOpenAgent={(slug) => { setBgTaskInitialSlug(slug); setBgTaskSlugVersion((v) => v + 1); openBgTasksView() }}
|
||||
recentRuns={runs}
|
||||
|
|
@ -5408,7 +5525,7 @@ function App() {
|
|||
canNavigateForward={canNavigateForward}
|
||||
collapsedLeftPaddingPx={collapsedLeftPaddingPx}
|
||||
>
|
||||
{(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen || isHomeOpen) && fileTabs.length >= 1 ? (
|
||||
{(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen || isHomeOpen || isCodeOpen) && fileTabs.length >= 1 ? (
|
||||
<TabBar
|
||||
tabs={fileTabs}
|
||||
activeTabId={activeFileTabId ?? ''}
|
||||
|
|
@ -5416,7 +5533,7 @@ function App() {
|
|||
getTabId={(t) => t.id}
|
||||
onSwitchTab={switchFileTab}
|
||||
onCloseTab={closeFileTab}
|
||||
allowSingleTabClose={fileTabs.length === 1 && (isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen || isHomeOpen || (selectedPath != null && isBaseFilePath(selectedPath)))}
|
||||
allowSingleTabClose={fileTabs.length === 1 && (isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen || isHomeOpen || isCodeOpen || (selectedPath != null && isBaseFilePath(selectedPath)))}
|
||||
/>
|
||||
) : isFullScreenChat ? (
|
||||
<ChatHeader
|
||||
|
|
@ -5481,7 +5598,7 @@ function App() {
|
|||
<TooltipContent side="bottom">Version history</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{!isFullScreenChat && !selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !isKnowledgeViewOpen && !isChatHistoryOpen && !selectedTask && !isBrowserOpen && (
|
||||
{!isFullScreenChat && !selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !isKnowledgeViewOpen && !isChatHistoryOpen && !isCodeOpen && !selectedTask && !isBrowserOpen && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
|
|
@ -5575,6 +5692,14 @@ function App() {
|
|||
meetingSummarizing={meetingSummarizing}
|
||||
/>
|
||||
</div>
|
||||
) : isCodeOpen ? (
|
||||
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
|
||||
<CodeView
|
||||
onSessionSelected={handleCodeSessionSelected}
|
||||
openDiffPath={codeDiffPath}
|
||||
onDiffOpened={() => setCodeDiffPath(null)}
|
||||
/>
|
||||
</div>
|
||||
) : isLiveNotesOpen ? (
|
||||
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
|
||||
<LiveNotesView
|
||||
|
|
@ -6010,6 +6135,7 @@ function App() {
|
|||
presetMessage={isActive ? presetMessage : undefined}
|
||||
onPresetMessageConsumed={isActive ? () => setPresetMessage(undefined) : undefined}
|
||||
runId={tabState.runId}
|
||||
codeSessionLock={tabState.runId ? codeSessionLocks[tabState.runId] ?? null : null}
|
||||
initialDraft={chatDraftsRef.current.get(tab.id)}
|
||||
onDraftChange={(text) => setChatDraftForTab(tab.id, text)}
|
||||
onSelectedModelChange={(m) => {
|
||||
|
|
@ -6044,8 +6170,22 @@ function App() {
|
|||
)}
|
||||
</SidebarInset>
|
||||
|
||||
{/* Chat pane - shown when viewing files/graph */}
|
||||
{isRightPaneContext && (
|
||||
{/* Chat pane - shown when viewing files/graph. For a direct-mode
|
||||
code session it swaps to the direct-drive chat; rowboat-mode
|
||||
sessions use the regular assistant chat bound to their run. */}
|
||||
{isRightPaneContext && isCodeOpen && activeCodeSession?.session.mode === 'direct' ? (
|
||||
<ResizableRightPane
|
||||
defaultWidth={DEFAULT_CHAT_PANE_WIDTH}
|
||||
onActivate={() => setActiveShortcutPane('right')}
|
||||
>
|
||||
<CodeChat
|
||||
key={activeCodeSession.session.id}
|
||||
session={activeCodeSession.session}
|
||||
status={activeCodeSession.status}
|
||||
onOpenDiff={setCodeDiffPath}
|
||||
/>
|
||||
</ResizableRightPane>
|
||||
) : isRightPaneContext && (
|
||||
<ChatSidebar
|
||||
placement={chatPanePlacement}
|
||||
paneSize={chatPaneSize}
|
||||
|
|
@ -6094,6 +6234,16 @@ function App() {
|
|||
}}
|
||||
workDirByTab={workDirByTab}
|
||||
onWorkDirChangeForTab={setTabWorkDir}
|
||||
codeSessionLocks={codeSessionLocks}
|
||||
pinnedToCodeSession={
|
||||
isCodeOpen
|
||||
&& activeCodeSession?.session.mode === 'rowboat'
|
||||
// Only while the pane is actually bound to the session — a
|
||||
// palette-initiated fresh chat, for example, unbinds it.
|
||||
&& chatTabs.find((t) => t.id === activeChatTabId)?.runId === activeCodeSession.session.id
|
||||
? { title: activeCodeSession.session.title }
|
||||
: null
|
||||
}
|
||||
pendingAskHumanRequests={pendingAskHumanRequests}
|
||||
allPermissionRequests={allPermissionRequests}
|
||||
permissionResponses={permissionResponses}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import type { ConversationItem } from '@/lib/chat-conversation'
|
|||
import { runLogToConversation } from '@/lib/run-to-conversation'
|
||||
import { CompactConversation } from '@/components/compact-conversation'
|
||||
import { RichMarkdownViewer } from '@/components/rich-markdown-viewer'
|
||||
import { HtmlFileViewer } from '@/components/html-file-viewer'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Trigger helpers (inlined; extract to shared <TriggersEditor> as a follow-up)
|
||||
|
|
@ -502,15 +503,22 @@ function SectionRegion({ label, children }: { label?: string; children: React.Re
|
|||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Output pane — index.md (main pane content)
|
||||
// Output pane — index.html (preferred) or index.md (main pane content)
|
||||
//
|
||||
// Renders the task's `index.md` like a note: max-width 720px centered, same
|
||||
// typography (~16px, 1.5 line-height, generous padding) as the note editor's
|
||||
// ProseMirror rule in `editor.css`. No chrome above the body — just the
|
||||
// markdown, with a small floating Source ⇄ Rendered toggle in the top-right.
|
||||
// A task's agent-owned artifact is either:
|
||||
// - `index.html` — a self-contained, styled web page. Rendered full-bleed in
|
||||
// a sandboxed iframe (via `HtmlFileViewer` / the `app://workspace`
|
||||
// protocol) so CSS, layout, and scripts render faithfully. Preferred when
|
||||
// present and non-empty.
|
||||
// - `index.md` — a note. Rendered like the note editor: max-width 720px
|
||||
// centered, same typography as `editor.css`, via `RichMarkdownViewer`.
|
||||
//
|
||||
// In both cases a small floating Source ⇄ Rendered toggle in the top-right
|
||||
// swaps the rendered view for the raw file source.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function OutputPane({ slug, taskName, refreshKey }: { slug: string; taskName: string; refreshKey: number }) {
|
||||
const [mode, setMode] = useState<'md' | 'html'>('md')
|
||||
const [body, setBody] = useState<string>('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [viewSource, setViewSource] = useState(false)
|
||||
|
|
@ -519,21 +527,33 @@ function OutputPane({ slug, taskName, refreshKey }: { slug: string; taskName: st
|
|||
let cancelled = false
|
||||
setLoading(true)
|
||||
void (async () => {
|
||||
// Prefer index.html when it exists and has content; otherwise fall
|
||||
// back to index.md (the default seeded artifact).
|
||||
try {
|
||||
const result = await window.ipc.invoke('workspace:readFile', {
|
||||
const html = await window.ipc.invoke('workspace:readFile', {
|
||||
path: `bg-tasks/${slug}/index.html`,
|
||||
})
|
||||
if (html.data.trim()) {
|
||||
if (!cancelled) { setMode('html'); setBody(html.data) }
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// No index.html — fall through to markdown.
|
||||
}
|
||||
try {
|
||||
const md = await window.ipc.invoke('workspace:readFile', {
|
||||
path: `bg-tasks/${slug}/index.md`,
|
||||
})
|
||||
if (!cancelled) setBody(result.data)
|
||||
if (!cancelled) { setMode('md'); setBody(md.data) }
|
||||
} catch {
|
||||
if (!cancelled) setBody('')
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false)
|
||||
if (!cancelled) { setMode('md'); setBody('') }
|
||||
}
|
||||
})()
|
||||
})().finally(() => { if (!cancelled) setLoading(false) })
|
||||
return () => { cancelled = true }
|
||||
}, [slug, refreshKey])
|
||||
|
||||
const isEmpty = !body.trim() || body.trim() === `# ${taskName}`
|
||||
const isEmpty = mode === 'md' && (!body.trim() || body.trim() === `# ${taskName}`)
|
||||
const showHtml = mode === 'html' && !viewSource
|
||||
|
||||
return (
|
||||
<div className="relative flex-1 overflow-hidden bg-background">
|
||||
|
|
@ -542,29 +562,35 @@ function OutputPane({ slug, taskName, refreshKey }: { slug: string; taskName: st
|
|||
type="button"
|
||||
onClick={() => setViewSource(v => !v)}
|
||||
className="absolute right-4 top-3 z-10 rounded-md bg-background/70 px-2 py-0.5 text-[11px] text-muted-foreground backdrop-blur hover:bg-accent hover:text-foreground"
|
||||
aria-label={viewSource ? 'Show rendered output' : 'Show source markdown'}
|
||||
aria-label={viewSource ? 'Show rendered output' : 'Show source'}
|
||||
>
|
||||
{viewSource ? 'Rendered' : 'Source'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="h-full overflow-y-auto">
|
||||
<div className="mx-auto max-w-[720px] px-16 py-8">
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Loader2 className="size-3 animate-spin" /> Loading…
|
||||
</div>
|
||||
) : isEmpty ? (
|
||||
<p className="text-sm italic text-muted-foreground">
|
||||
No output yet. Click <span className="font-medium text-foreground">Run now</span> in the sidebar, or wait for a trigger to fire.
|
||||
</p>
|
||||
) : viewSource ? (
|
||||
<pre className="overflow-x-auto whitespace-pre-wrap font-mono text-[13px] leading-relaxed">{body}</pre>
|
||||
) : (
|
||||
<RichMarkdownViewer content={body} />
|
||||
)}
|
||||
{showHtml ? (
|
||||
// Full-bleed: the iframe fills the pane and scrolls internally.
|
||||
// Remount on refreshKey so a re-run's updated index.html reloads.
|
||||
<HtmlFileViewer key={`${slug}-${refreshKey}`} path={`bg-tasks/${slug}/index.html`} />
|
||||
) : (
|
||||
<div className="h-full overflow-y-auto">
|
||||
<div className="mx-auto max-w-[720px] px-16 py-8">
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Loader2 className="size-3 animate-spin" /> Loading…
|
||||
</div>
|
||||
) : isEmpty ? (
|
||||
<p className="text-sm italic text-muted-foreground">
|
||||
No output yet. Click <span className="font-medium text-foreground">Run now</span> in the sidebar, or wait for a trigger to fire.
|
||||
</p>
|
||||
) : viewSource ? (
|
||||
<pre className="overflow-x-auto whitespace-pre-wrap font-mono text-[13px] leading-relaxed">{body}</pre>
|
||||
) : (
|
||||
<RichMarkdownViewer content={body} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import {
|
|||
Headphones,
|
||||
ImagePlus,
|
||||
LoaderIcon,
|
||||
Lock,
|
||||
Mic,
|
||||
MoreHorizontal,
|
||||
Plus,
|
||||
|
|
@ -237,6 +238,12 @@ interface ChatInputInnerProps {
|
|||
workDir?: string | null
|
||||
/** Fired when the user sets/changes/clears the work directory for this chat. */
|
||||
onWorkDirChange?: (value: string | null) => void
|
||||
/**
|
||||
* Set when this chat is bound to a Code-section session: the work directory
|
||||
* and coding agent come from the session and are FROZEN — the backend pins
|
||||
* them server-side regardless, so the composer must not pretend otherwise.
|
||||
*/
|
||||
codeSessionLock?: { cwd: string; agent: 'claude' | 'codex' } | null
|
||||
}
|
||||
|
||||
function ChatInputInner({
|
||||
|
|
@ -265,6 +272,7 @@ function ChatInputInner({
|
|||
onSelectedModelChange,
|
||||
workDir = null,
|
||||
onWorkDirChange,
|
||||
codeSessionLock = null,
|
||||
}: ChatInputInnerProps) {
|
||||
const controller = usePromptInputController()
|
||||
const message = controller.textInput.value
|
||||
|
|
@ -491,22 +499,33 @@ function ChatInputInner({
|
|||
})
|
||||
}, [])
|
||||
|
||||
// A chat bound to a Code-section session has its work directory and coding
|
||||
// agent frozen to the session's — the backend pins them server-side, so the
|
||||
// composer reflects that instead of offering controls that wouldn't apply.
|
||||
const isCodeLocked = Boolean(codeSessionLock)
|
||||
const effectiveWorkDir = codeSessionLock?.cwd ?? workDir
|
||||
|
||||
// Work directory is owned per-chat by the parent (App). This component only
|
||||
// drives the picker dialog and reports changes up via onWorkDirChange. Whenever
|
||||
// the work directory changes, load its persisted coding-agent preference.
|
||||
useEffect(() => {
|
||||
if (codeSessionLock) {
|
||||
setCodingAgent(codeSessionLock.agent)
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
loadCodingAgentFor(workDir).then((agent) => {
|
||||
if (!cancelled) setCodingAgent(agent)
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
}, [workDir, loadCodingAgentFor])
|
||||
}, [workDir, loadCodingAgentFor, codeSessionLock])
|
||||
|
||||
useEffect(() => {
|
||||
if (isActive && workDir) void rememberWorkDir(workDir)
|
||||
}, [isActive, workDir, rememberWorkDir])
|
||||
if (isActive && workDir && !isCodeLocked) void rememberWorkDir(workDir)
|
||||
}, [isActive, workDir, rememberWorkDir, isCodeLocked])
|
||||
|
||||
const handleSetWorkDir = useCallback(async () => {
|
||||
if (isCodeLocked) return
|
||||
try {
|
||||
let defaultPath: string | undefined = workDir ?? undefined
|
||||
try {
|
||||
|
|
@ -533,7 +552,7 @@ function ChatInputInner({
|
|||
console.error('Failed to set work directory', err)
|
||||
toast.error('Failed to set work directory')
|
||||
}
|
||||
}, [workDir, onWorkDirChange, rememberWorkDir, loadCodingAgentFor])
|
||||
}, [workDir, onWorkDirChange, rememberWorkDir, loadCodingAgentFor, isCodeLocked])
|
||||
|
||||
const handleSelectRecentWorkDir = useCallback(async (dir: string) => {
|
||||
onWorkDirChange?.(dir)
|
||||
|
|
@ -543,12 +562,14 @@ function ChatInputInner({
|
|||
}, [onWorkDirChange, rememberWorkDir, loadCodingAgentFor])
|
||||
|
||||
const handleClearWorkDir = useCallback(() => {
|
||||
if (isCodeLocked) return
|
||||
onWorkDirChange?.(null)
|
||||
setCodingAgent('claude')
|
||||
toast.success('Work directory cleared')
|
||||
}, [onWorkDirChange])
|
||||
}, [onWorkDirChange, isCodeLocked])
|
||||
|
||||
const handleToggleCodingAgent = useCallback(async () => {
|
||||
if (isCodeLocked) return
|
||||
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.
|
||||
|
|
@ -561,7 +582,7 @@ function ChatInputInner({
|
|||
// revert on failure
|
||||
setCodingAgent(codingAgent)
|
||||
}
|
||||
}, [workDir, codingAgent, persistCodingAgent])
|
||||
}, [workDir, codingAgent, persistCodingAgent, isCodeLocked])
|
||||
|
||||
// Check search tool availability (exa or signed-in via gateway)
|
||||
useEffect(() => {
|
||||
|
|
@ -647,15 +668,16 @@ function ChatInputInner({
|
|||
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (!canSubmit) return
|
||||
// codeMode is sticky per conversation — don't reset after send.
|
||||
const effectiveCodeMode = codeModeEnabled ? codingAgent : undefined
|
||||
// codeMode is sticky per conversation — don't reset after send. A code
|
||||
// session forces it (the backend pins the agent anyway).
|
||||
const effectiveCodeMode = codeSessionLock ? codeSessionLock.agent : (codeModeEnabled ? codingAgent : undefined)
|
||||
onSubmit({ text: message.trim(), files: [] }, controller.mentions.mentions, attachments, searchEnabled || undefined, effectiveCodeMode, permissionMode)
|
||||
controller.textInput.clear()
|
||||
controller.mentions.clearMentions()
|
||||
setAttachments([])
|
||||
// Web search toggle stays on for the rest of the chat session; the user
|
||||
// turns it off explicitly. (Not persisted across app restarts.)
|
||||
}, [attachments, canSubmit, controller, message, onSubmit, searchEnabled, codeModeEnabled, codingAgent, permissionMode, workDir])
|
||||
}, [attachments, canSubmit, controller, message, onSubmit, searchEnabled, codeModeEnabled, codingAgent, permissionMode, workDir, codeSessionLock])
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
|
|
@ -697,8 +719,8 @@ function ChatInputInner({
|
|||
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) : ''
|
||||
const currentWorkDirLabel = effectiveWorkDir ? basename(effectiveWorkDir) || effectiveWorkDir : 'Not set'
|
||||
const currentWorkDirPath = effectiveWorkDir ? compactWorkDirPath(effectiveWorkDir) : ''
|
||||
|
||||
return (
|
||||
<div className="rowboat-chat-input rounded-lg border border-border bg-background shadow-none">
|
||||
|
|
@ -820,7 +842,7 @@ function ChatInputInner({
|
|||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
{workDir ? 'Add files or change work directory' : 'Add files or set work directory'}
|
||||
{isCodeLocked ? 'Add files' : 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">
|
||||
|
|
@ -830,8 +852,21 @@ function ChatInputInner({
|
|||
<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. */}
|
||||
{/* A bound code session pins the directory — show it, no controls. */}
|
||||
{isCodeLocked ? (
|
||||
<div
|
||||
title={effectiveWorkDir ?? undefined}
|
||||
className="flex h-auto items-center gap-2 rounded-[9px] px-2.5 py-2 text-muted-foreground"
|
||||
>
|
||||
<FolderCheck className="size-4 shrink-0" />
|
||||
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<span className="truncate text-sm">{currentWorkDirLabel}</span>
|
||||
<span className="truncate text-xs">Pinned by the coding session</span>
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
/* 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" />
|
||||
|
|
@ -907,26 +942,31 @@ function ChatInputInner({
|
|||
)}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{workDir && collapseLevel < 8 && (
|
||||
{effectiveWorkDir && 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",
|
||||
"group flex h-7 shrink-0 items-center rounded-full border border-border bg-muted/40 text-xs text-muted-foreground transition-colors",
|
||||
!isCodeLocked && "hover:bg-muted hover:text-foreground",
|
||||
collapseLevel >= 4 ? "w-7 justify-center" : "max-w-[180px] pl-2.5 pr-2"
|
||||
)}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSetWorkDir}
|
||||
className="flex min-w-0 items-center gap-1.5"
|
||||
disabled={isCodeLocked}
|
||||
className={cn("flex min-w-0 items-center gap-1.5", isCodeLocked && "cursor-default")}
|
||||
>
|
||||
<FolderCog className="h-3.5 w-3.5 shrink-0" />
|
||||
{collapseLevel < 4 && <span className="truncate">{basename(workDir) || workDir}</span>}
|
||||
{isCodeLocked
|
||||
? <Lock className="h-3 w-3 shrink-0" />
|
||||
: <FolderCog className="h-3.5 w-3.5 shrink-0" />}
|
||||
{collapseLevel < 4 && <span className="truncate">{basename(effectiveWorkDir) || effectiveWorkDir}</span>}
|
||||
</button>
|
||||
{collapseLevel < 4 && (
|
||||
{collapseLevel < 4 && !isCodeLocked && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClearWorkDir}
|
||||
|
|
@ -939,7 +979,9 @@ function ChatInputInner({
|
|||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
Work directory: {workDir}
|
||||
{isCodeLocked
|
||||
? `Pinned by the coding session: ${effectiveWorkDir}`
|
||||
: `Work directory: ${effectiveWorkDir}`}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
|
@ -997,20 +1039,28 @@ function ChatInputInner({
|
|||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{codeModeFeatureEnabled && collapseLevel < 5 && (codeModeEnabled ? (
|
||||
{codeModeFeatureEnabled && collapseLevel < 5 && ((isCodeLocked || 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"
|
||||
onClick={() => { if (!isCodeLocked) setCodeModeEnabled(false) }}
|
||||
disabled={isCodeLocked}
|
||||
className={cn(
|
||||
"flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-secondary text-foreground transition-colors",
|
||||
isCodeLocked ? "cursor-default" : "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>
|
||||
<TooltipContent side="top">
|
||||
{isCodeLocked
|
||||
? `Coding session — ${codingAgent === 'claude' ? 'Claude Code' : 'Codex'}`
|
||||
: `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">
|
||||
|
|
@ -1018,14 +1068,20 @@ function ChatInputInner({
|
|||
<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"
|
||||
onClick={() => { if (!isCodeLocked) setCodeModeEnabled(false) }}
|
||||
disabled={isCodeLocked}
|
||||
className={cn(
|
||||
"flex h-full items-center gap-1.5 rounded-l-full pl-2.5 pr-2 transition-colors",
|
||||
isCodeLocked ? "cursor-default" : "hover:bg-secondary/70",
|
||||
)}
|
||||
>
|
||||
<Terminal className="h-3.5 w-3.5" />
|
||||
{isCodeLocked ? <Lock className="h-3 w-3" /> : <Terminal className="h-3.5 w-3.5" />}
|
||||
<span>Code</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Code mode on — click to disable</TooltipContent>
|
||||
<TooltipContent side="top">
|
||||
{isCodeLocked ? 'Pinned by the coding session' : 'Code mode on — click to disable'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<span className="text-foreground/30">·</span>
|
||||
<Tooltip>
|
||||
|
|
@ -1033,13 +1089,19 @@ function ChatInputInner({
|
|||
<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"
|
||||
disabled={isCodeLocked}
|
||||
className={cn(
|
||||
"flex h-full items-center rounded-r-full pl-2 pr-2.5 transition-colors",
|
||||
isCodeLocked ? "cursor-default" : "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
|
||||
{isCodeLocked
|
||||
? `Coding agent fixed by the session: ${codingAgent === 'claude' ? 'Claude Code' : 'Codex'}`
|
||||
: `Coding agent: ${codingAgent === 'claude' ? 'Claude Code' : 'Codex'} — click to swap`}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
|
@ -1077,10 +1139,10 @@ function ChatInputInner({
|
|||
<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>
|
||||
{effectiveWorkDir && collapseLevel >= 8 && (
|
||||
<DropdownMenuItem disabled={isCodeLocked} onSelect={() => { void handleSetWorkDir() }}>
|
||||
{isCodeLocked ? <Lock className="size-4" /> : <FolderCog className="size-4" />}
|
||||
<span className="min-w-0 flex-1 truncate">{basename(effectiveWorkDir) || effectiveWorkDir}</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{searchAvailable && collapseLevel >= 7 && (
|
||||
|
|
@ -1105,14 +1167,15 @@ function ChatInputInner({
|
|||
{codeModeFeatureEnabled && collapseLevel >= 5 && (
|
||||
<>
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={codeModeEnabled}
|
||||
checked={isCodeLocked || codeModeEnabled}
|
||||
disabled={isCodeLocked}
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
onCheckedChange={(c) => setCodeModeEnabled(Boolean(c))}
|
||||
>
|
||||
Code mode
|
||||
</DropdownMenuCheckboxItem>
|
||||
{codeModeEnabled && (
|
||||
<DropdownMenuItem onSelect={(e) => { e.preventDefault(); handleToggleCodingAgent() }}>
|
||||
{(isCodeLocked || codeModeEnabled) && (
|
||||
<DropdownMenuItem disabled={isCodeLocked} 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>
|
||||
|
|
@ -1308,6 +1371,8 @@ export interface ChatInputWithMentionsProps {
|
|||
onSelectedModelChange?: (model: SelectedModel | null) => void
|
||||
workDir?: string | null
|
||||
onWorkDirChange?: (value: string | null) => void
|
||||
/** Set when this chat is bound to a Code-section session — freezes workdir + agent. */
|
||||
codeSessionLock?: { cwd: string; agent: 'claude' | 'codex' } | null
|
||||
}
|
||||
|
||||
export function ChatInputWithMentions({
|
||||
|
|
@ -1339,6 +1404,7 @@ export function ChatInputWithMentions({
|
|||
onSelectedModelChange,
|
||||
workDir,
|
||||
onWorkDirChange,
|
||||
codeSessionLock,
|
||||
}: ChatInputWithMentionsProps) {
|
||||
return (
|
||||
<PromptInputProvider knowledgeFiles={knowledgeFiles} recentFiles={recentFiles} visibleFiles={visibleFiles}>
|
||||
|
|
@ -1368,6 +1434,7 @@ export function ChatInputWithMentions({
|
|||
onSelectedModelChange={onSelectedModelChange}
|
||||
workDir={workDir}
|
||||
onWorkDirChange={onWorkDirChange}
|
||||
codeSessionLock={codeSessionLock}
|
||||
/>
|
||||
</PromptInputProvider>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { ArrowLeft, ArrowRight, Bug, MoreHorizontal } from 'lucide-react'
|
||||
import { ArrowLeft, ArrowRight, Bug, MoreHorizontal, Pin } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
|
@ -155,6 +155,13 @@ interface ChatSidebarProps {
|
|||
onDraftChangeForTab?: (tabId: string, text: string) => void
|
||||
onSelectedModelChangeForTab?: (tabId: string, model: SelectedModel | null) => void
|
||||
workDirByTab?: Record<string, string | null>
|
||||
/** Composer locks for runs bound to Code-section sessions (cwd + agent frozen). */
|
||||
codeSessionLocks?: Record<string, { cwd: string; agent: 'claude' | 'codex' }>
|
||||
/**
|
||||
* Set while a Rowboat-mode code session owns this pane: the chat is pinned to
|
||||
* the session, so the chat switcher / new-chat / history affordances hide.
|
||||
*/
|
||||
pinnedToCodeSession?: { title: string } | null
|
||||
onWorkDirChangeForTab?: (tabId: string, value: string | null) => void
|
||||
pendingAskHumanRequests?: ChatTabViewState['pendingAskHumanRequests']
|
||||
allPermissionRequests?: ChatTabViewState['allPermissionRequests']
|
||||
|
|
@ -216,6 +223,8 @@ export function ChatSidebar({
|
|||
onDraftChangeForTab,
|
||||
onSelectedModelChangeForTab,
|
||||
workDirByTab = {},
|
||||
codeSessionLocks = {},
|
||||
pinnedToCodeSession = null,
|
||||
onWorkDirChangeForTab,
|
||||
pendingAskHumanRequests = new Map(),
|
||||
allPermissionRequests = new Map(),
|
||||
|
|
@ -555,17 +564,34 @@ export function ChatSidebar({
|
|||
transition: isMaximized ? 'padding-left 200ms linear' : undefined,
|
||||
}}
|
||||
>
|
||||
<ChatHeader
|
||||
activeTitle={(() => {
|
||||
const activeTab = chatTabs.find((tab) => tab.id === activeChatTabId)
|
||||
return activeTab ? getChatTabTitle(activeTab) : 'New chat'
|
||||
})()}
|
||||
onNewChatTab={onNewChatTab}
|
||||
recentRuns={recentRuns}
|
||||
activeRunId={runId}
|
||||
onSelectRun={onSelectRun}
|
||||
onOpenChatHistory={onOpenChatHistory}
|
||||
/>
|
||||
{pinnedToCodeSession ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="titlebar-no-drag flex min-w-0 flex-1 items-center gap-1.5 px-3 py-2 text-sm font-medium">
|
||||
<Pin className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 truncate">{pinnedToCodeSession.title}</span>
|
||||
<span className="shrink-0 rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-normal text-muted-foreground">
|
||||
Coding session
|
||||
</span>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
This chat is pinned to the coding session — leave the Code view to switch chats.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<ChatHeader
|
||||
activeTitle={(() => {
|
||||
const activeTab = chatTabs.find((tab) => tab.id === activeChatTabId)
|
||||
return activeTab ? getChatTabTitle(activeTab) : 'New chat'
|
||||
})()}
|
||||
onNewChatTab={onNewChatTab}
|
||||
recentRuns={recentRuns}
|
||||
activeRunId={runId}
|
||||
onSelectRun={onSelectRun}
|
||||
onOpenChatHistory={onOpenChatHistory}
|
||||
/>
|
||||
)}
|
||||
<DropdownMenu>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
|
@ -646,9 +672,11 @@ export function ChatSidebar({
|
|||
{!tabHasConversation ? (
|
||||
<ChatEmptyState
|
||||
wide={isMaximized}
|
||||
recentRuns={recentRuns}
|
||||
onSelectRun={onSelectRun}
|
||||
onOpenChatHistory={onOpenChatHistory}
|
||||
// A pinned coding-session chat must not offer jumping
|
||||
// to other conversations from the empty state either.
|
||||
recentRuns={pinnedToCodeSession ? [] : recentRuns}
|
||||
onSelectRun={pinnedToCodeSession ? undefined : onSelectRun}
|
||||
onOpenChatHistory={pinnedToCodeSession ? undefined : onOpenChatHistory}
|
||||
onPickPrompt={setLocalPresetMessage}
|
||||
/>
|
||||
) : (
|
||||
|
|
@ -779,6 +807,7 @@ export function ChatSidebar({
|
|||
onSelectedModelChange={onSelectedModelChangeForTab ? (m) => onSelectedModelChangeForTab(tab.id, m) : undefined}
|
||||
workDir={workDirByTab[tab.id] ?? null}
|
||||
onWorkDirChange={onWorkDirChangeForTab ? (v) => onWorkDirChangeForTab(tab.id, v) : undefined}
|
||||
codeSessionLock={tabState.runId ? codeSessionLocks[tabState.runId] ?? null : null}
|
||||
isRecording={isActive && isRecording}
|
||||
recordingText={isActive ? recordingText : undefined}
|
||||
recordingState={isActive ? recordingState : undefined}
|
||||
|
|
|
|||
84
apps/x/apps/renderer/src/components/code/cm.ts
Normal file
84
apps/x/apps/renderer/src/components/code/cm.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import { EditorView, lineNumbers } from '@codemirror/view'
|
||||
import { EditorState, type Extension } from '@codemirror/state'
|
||||
import {
|
||||
HighlightStyle,
|
||||
LanguageDescription,
|
||||
bracketMatching,
|
||||
syntaxHighlighting,
|
||||
defaultHighlightStyle,
|
||||
} from '@codemirror/language'
|
||||
import { languages } from '@codemirror/language-data'
|
||||
import { tags } from '@lezer/highlight'
|
||||
|
||||
// Shared CodeMirror setup for the Code section's read-only viewers
|
||||
// (file viewer + diff viewer). Theming keys off the app's resolved theme
|
||||
// instead of pulling in a theme package.
|
||||
|
||||
const darkHighlight = HighlightStyle.define([
|
||||
{ tag: tags.keyword, color: '#c678dd' },
|
||||
{ tag: [tags.name, tags.deleted, tags.character, tags.macroName], color: '#e06c75' },
|
||||
{ tag: [tags.function(tags.variableName), tags.labelName], color: '#61afef' },
|
||||
{ tag: [tags.color, tags.constant(tags.name), tags.standard(tags.name)], color: '#d19a66' },
|
||||
{ tag: [tags.definition(tags.name), tags.separator], color: '#abb2bf' },
|
||||
{ tag: [tags.typeName, tags.className, tags.number, tags.changed, tags.annotation, tags.modifier, tags.self, tags.namespace], color: '#e5c07b' },
|
||||
{ tag: [tags.operator, tags.operatorKeyword, tags.url, tags.escape, tags.regexp, tags.link, tags.special(tags.string)], color: '#56b6c2' },
|
||||
{ tag: [tags.meta, tags.comment], color: '#7d8799', fontStyle: 'italic' },
|
||||
{ tag: [tags.atom, tags.bool, tags.special(tags.variableName)], color: '#d19a66' },
|
||||
{ tag: [tags.processingInstruction, tags.string, tags.inserted], color: '#98c379' },
|
||||
{ tag: tags.invalid, color: '#ffffff' },
|
||||
])
|
||||
|
||||
export function cmBaseExtensions(isDark: boolean): Extension[] {
|
||||
return [
|
||||
lineNumbers(),
|
||||
bracketMatching(),
|
||||
syntaxHighlighting(isDark ? darkHighlight : defaultHighlightStyle, { fallback: true }),
|
||||
EditorView.lineWrapping,
|
||||
EditorState.readOnly.of(true),
|
||||
EditorView.editable.of(false),
|
||||
EditorView.theme(
|
||||
{
|
||||
'&': {
|
||||
backgroundColor: 'transparent',
|
||||
fontSize: '12px',
|
||||
height: '100%',
|
||||
},
|
||||
'.cm-scroller': {
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
|
||||
overflow: 'auto',
|
||||
},
|
||||
'.cm-gutters': {
|
||||
backgroundColor: 'transparent',
|
||||
border: 'none',
|
||||
color: isDark ? '#6b7280' : '#9ca3af',
|
||||
},
|
||||
'&.cm-focused': { outline: 'none' },
|
||||
// GitHub-style expander bar for folded unchanged regions (@codemirror/merge).
|
||||
'.cm-collapsedLines': {
|
||||
backgroundColor: isDark ? 'rgba(56, 139, 253, 0.15)' : 'rgba(9, 105, 218, 0.08)',
|
||||
backgroundImage: 'none',
|
||||
color: isDark ? '#79c0ff' : '#0969da',
|
||||
padding: '4px 12px',
|
||||
fontSize: '11px',
|
||||
cursor: 'pointer',
|
||||
},
|
||||
'.cm-collapsedLines:hover': {
|
||||
backgroundColor: isDark ? 'rgba(56, 139, 253, 0.25)' : 'rgba(9, 105, 218, 0.15)',
|
||||
},
|
||||
},
|
||||
{ dark: isDark },
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
// Resolve a language extension from the filename (lazy-loaded; Vite splits
|
||||
// each language into its own chunk).
|
||||
export async function cmLanguageFor(filename: string): Promise<Extension | null> {
|
||||
const desc = LanguageDescription.matchFilename(languages, filename)
|
||||
if (!desc) return null
|
||||
try {
|
||||
return await desc.load()
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
350
apps/x/apps/renderer/src/components/code/code-chat.tsx
Normal file
350
apps/x/apps/renderer/src/components/code/code-chat.tsx
Normal file
|
|
@ -0,0 +1,350 @@
|
|||
import { useEffect, useRef, useState } from 'react'
|
||||
import { ArrowUp, FileText, Loader2, LoaderIcon, Plus, Square, Terminal, X } from 'lucide-react'
|
||||
import type { CodeSession, CodeSessionStatus } from '@x/shared/src/code-sessions.js'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { Conversation, ConversationContent, ConversationScrollButton } from '@/components/ai-elements/conversation'
|
||||
import { MessageResponse } from '@/components/ai-elements/message'
|
||||
import { Shimmer } from '@/components/ai-elements/shimmer'
|
||||
import { Tool, ToolContent, ToolHeader } from '@/components/ai-elements/tool'
|
||||
import { toToolState, getToolDisplayName, getWebSearchCardData, type ToolCall } from '@/lib/chat-conversation'
|
||||
import { CodeRunPermissionRequest, CodingRunTimeline } from '@/components/coding-run'
|
||||
import { PermissionRequest } from '@/components/ai-elements/permission-request'
|
||||
import { AskHumanRequest } from '@/components/ai-elements/ask-human-request'
|
||||
import { WebSearchResult } from '@/components/ai-elements/web-search-result'
|
||||
import { useCodeChat, isDirectTurn, isChatToolCall, isChatErrorMessage, type CodeChatItem } from './use-code-chat'
|
||||
|
||||
const AGENT_LABEL: Record<string, string> = { claude: 'Claude Code', codex: 'Codex' }
|
||||
|
||||
function RowboatToolCall({ item, onOpenDiff }: { item: ToolCall; onOpenDiff: (path: string) => void }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const webSearch = getWebSearchCardData(item)
|
||||
if (webSearch) {
|
||||
return (
|
||||
<WebSearchResult
|
||||
query={webSearch.query}
|
||||
results={webSearch.results}
|
||||
status={item.status}
|
||||
title={webSearch.title}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (item.name === 'code_agent_run') {
|
||||
const agent = (item.result as { agent?: string } | undefined)?.agent
|
||||
?? (item.input as { agent?: string } | undefined)?.agent
|
||||
return (
|
||||
<Tool open={open || item.status === 'running'} onOpenChange={setOpen}>
|
||||
<ToolHeader title={AGENT_LABEL[agent ?? ''] ?? 'Coding agent'} type="tool-code_agent_run" state={toToolState(item.status)} />
|
||||
<ToolContent>
|
||||
<CodingRunTimeline events={item.codeRunEvents ?? []} onOpenDiff={onOpenDiff} />
|
||||
</ToolContent>
|
||||
</Tool>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
{item.status === 'running' || item.status === 'pending'
|
||||
? <Loader2 className="size-3 animate-spin" />
|
||||
: <span className="text-green-600">✓</span>}
|
||||
<span className="truncate">{getToolDisplayName(item)}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ChatItem({ item, onOpenDiff }: { item: CodeChatItem; onOpenDiff: (path: string) => void }) {
|
||||
if (isDirectTurn(item)) {
|
||||
if (item.events.length === 0) return null
|
||||
return (
|
||||
<div className="rounded-[16px] border bg-muted/20">
|
||||
<CodingRunTimeline events={item.events} onOpenDiff={onOpenDiff} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (isChatErrorMessage(item)) {
|
||||
return (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive">
|
||||
{item.message.split('\n')[0]}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (isChatToolCall(item)) {
|
||||
return <RowboatToolCall item={item} onOpenDiff={onOpenDiff} />
|
||||
}
|
||||
if (item.role === 'user') {
|
||||
return (
|
||||
<div className="flex justify-end">
|
||||
<div className="min-w-0 max-w-[85%] whitespace-pre-wrap break-words rounded-2xl bg-primary/10 px-4 py-2.5 text-sm">
|
||||
{item.content}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="min-w-0 max-w-none break-words text-sm">
|
||||
<MessageResponse>{item.content}</MessageResponse>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Direct-drive chat for one coding session, rendered in the right-side pane in
|
||||
// place of the assistant chat. Messages go straight to the ACP agent — when the
|
||||
// session is in Rowboat mode this component isn't used (the real assistant
|
||||
// chat pane is, bound to the session's run).
|
||||
export function CodeChat({
|
||||
session,
|
||||
status,
|
||||
onOpenDiff,
|
||||
}: {
|
||||
session: CodeSession
|
||||
status: CodeSessionStatus
|
||||
onOpenDiff: (path: string) => void
|
||||
}) {
|
||||
const {
|
||||
items, liveText, isProcessing, pendingPermission, pendingToolPermissions, pendingAskHumans,
|
||||
loading, send, stop, resolvePermission, respondToToolPermission, respondToAskHuman,
|
||||
} = useCodeChat(session)
|
||||
const [draft, setDraft] = useState('')
|
||||
const [stopping, setStopping] = useState(false)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
const busy = isProcessing || status === 'working' || status === 'needs-you'
|
||||
// Attached file PATHS — like dragging a file into the Claude Code CLI, the
|
||||
// agent receives paths and reads the files itself with its own tools.
|
||||
const [attachments, setAttachments] = useState<string[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
setDraft('')
|
||||
setAttachments([])
|
||||
setStopping(false)
|
||||
textareaRef.current?.focus()
|
||||
}, [session.id])
|
||||
|
||||
useEffect(() => {
|
||||
if (!busy) setStopping(false)
|
||||
}, [busy])
|
||||
|
||||
const addAttachments = (paths: string[]) => {
|
||||
const cleaned = paths.filter(Boolean)
|
||||
if (cleaned.length === 0) return
|
||||
setAttachments((prev) => [...prev, ...cleaned.filter((p) => !prev.includes(p))])
|
||||
}
|
||||
|
||||
const handlePickFiles = async () => {
|
||||
const res = await window.ipc.invoke('dialog:openFiles', {
|
||||
title: 'Attach files',
|
||||
defaultPath: session.cwd,
|
||||
})
|
||||
addAttachments(res.paths)
|
||||
textareaRef.current?.focus()
|
||||
}
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
if (!e.dataTransfer?.files?.length) return
|
||||
e.preventDefault()
|
||||
const paths = Array.from(e.dataTransfer.files)
|
||||
.map((file) => window.electronUtils?.getPathForFile(file))
|
||||
.filter(Boolean) as string[]
|
||||
addAttachments(paths)
|
||||
}
|
||||
|
||||
const canSend = (Boolean(draft.trim()) || attachments.length > 0) && !busy
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!canSend) return
|
||||
const text = draft.trim()
|
||||
const files = attachments
|
||||
// The agent gets paths, CLI-style; it reads them from disk on its own.
|
||||
const message = files.length > 0
|
||||
? `${text || 'Look at the attached files.'}\n\nAttached files (read them from disk):\n${files.map((p) => `- ${p}`).join('\n')}`
|
||||
: text
|
||||
setDraft('')
|
||||
setAttachments([])
|
||||
const result = await send(message)
|
||||
if (!result.ok && result.error) {
|
||||
toast.error(result.error)
|
||||
setDraft(text)
|
||||
setAttachments(files)
|
||||
}
|
||||
}
|
||||
|
||||
const handleStop = async () => {
|
||||
setStopping(true)
|
||||
await stop()
|
||||
}
|
||||
|
||||
const basename = (p: string) => p.split(/[\\/]/).pop() || p
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex h-full min-h-0 flex-col"
|
||||
onDragOver={(e) => { if (e.dataTransfer?.types?.includes('Files')) e.preventDefault() }}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
{/* Slim header — session controls live in the Code view's middle header */}
|
||||
<div className="flex items-center gap-2 border-b px-4 py-2">
|
||||
<Terminal className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium">{session.title}</div>
|
||||
<div className="text-[11px] text-muted-foreground">{AGENT_LABEL[session.agent]} — direct</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Conversation */}
|
||||
<Conversation className="min-h-0 flex-1">
|
||||
<ConversationContent className="mx-auto flex w-full max-w-3xl flex-col gap-4 px-4 py-4">
|
||||
{loading && <div className="text-sm text-muted-foreground">Loading conversation…</div>}
|
||||
{!loading && items.length === 0 && !busy && (
|
||||
<div className="flex flex-col items-center gap-2 py-16 text-center">
|
||||
<div className="text-sm font-medium">
|
||||
Talk directly to {AGENT_LABEL[session.agent]}
|
||||
</div>
|
||||
<p className="max-w-sm text-xs text-muted-foreground">
|
||||
Your messages go straight to the coding agent in this project. Tool calls, plans, and diffs stream in here.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{items.map((item) => (
|
||||
<ChatItem key={item.id} item={item} onOpenDiff={onOpenDiff} />
|
||||
))}
|
||||
{liveText && (
|
||||
<div className="min-w-0 max-w-none break-words text-sm">
|
||||
<MessageResponse>{liveText.replace(/<\/?voice>/g, '')}</MessageResponse>
|
||||
</div>
|
||||
)}
|
||||
{pendingPermission && (
|
||||
<CodeRunPermissionRequest ask={pendingPermission.ask} onDecide={(d) => void resolvePermission(d)} />
|
||||
)}
|
||||
{Array.from(pendingToolPermissions.values()).map((request) => (
|
||||
<PermissionRequest
|
||||
key={request.toolCall.toolCallId}
|
||||
toolCall={request.toolCall}
|
||||
permission={request.permission}
|
||||
onApprove={() => void respondToToolPermission(request.toolCall.toolCallId, request.subflow, 'approve')}
|
||||
onApproveSession={() => void respondToToolPermission(request.toolCall.toolCallId, request.subflow, 'approve', 'session')}
|
||||
onApproveAlways={() => void respondToToolPermission(request.toolCall.toolCallId, request.subflow, 'approve', 'always')}
|
||||
onDeny={() => void respondToToolPermission(request.toolCall.toolCallId, request.subflow, 'deny')}
|
||||
isProcessing={busy}
|
||||
/>
|
||||
))}
|
||||
{Array.from(pendingAskHumans.values()).map((request) => (
|
||||
<AskHumanRequest
|
||||
key={request.toolCallId}
|
||||
query={request.query}
|
||||
options={request.options}
|
||||
onResponse={(response) => void respondToAskHuman(request.toolCallId, request.subflow, response)}
|
||||
isProcessing={busy}
|
||||
/>
|
||||
))}
|
||||
{busy && !pendingPermission && pendingToolPermissions.size === 0 && pendingAskHumans.size === 0 && (
|
||||
<Shimmer className="text-sm">
|
||||
{stopping ? 'Stopping…' : `${AGENT_LABEL[session.agent]} is working…`}
|
||||
</Shimmer>
|
||||
)}
|
||||
</ConversationContent>
|
||||
<ConversationScrollButton />
|
||||
</Conversation>
|
||||
|
||||
{/* Composer — mirrors the assistant chat input's look (rounded card,
|
||||
borderless textarea, round primary send / destructive stop). */}
|
||||
<div className="p-3">
|
||||
<div className="rowboat-chat-input mx-auto w-full max-w-3xl rounded-lg border border-border bg-background shadow-none">
|
||||
{attachments.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 px-4 pb-1 pt-3">
|
||||
{attachments.map((p) => (
|
||||
<span
|
||||
key={p}
|
||||
title={p}
|
||||
className="group inline-flex max-w-[260px] items-center gap-1.5 rounded-xl border border-border/50 bg-muted/80 px-2.5 py-1.5 text-xs"
|
||||
>
|
||||
<FileText className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 truncate">{basename(p)}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAttachments((prev) => prev.filter((x) => x !== p))}
|
||||
aria-label="Remove attachment"
|
||||
className="flex size-4 shrink-0 items-center justify-center rounded-full text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="px-4 pb-2 pt-4">
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
void handleSend()
|
||||
}
|
||||
}}
|
||||
placeholder="Type your message..."
|
||||
className="max-h-40 min-h-[24px] w-full resize-none border-0 bg-transparent p-0 text-sm shadow-none outline-none focus-visible:ring-0"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 px-3 pb-3">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handlePickFiles()}
|
||||
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="Attach files"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Attach files — the agent reads them from disk (or drag & drop)</TooltipContent>
|
||||
</Tooltip>
|
||||
<span className="flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Terminal className="size-3.5 shrink-0" />
|
||||
<span className="truncate">Direct — straight to {AGENT_LABEL[session.agent]}</span>
|
||||
</span>
|
||||
<div className="flex-1" />
|
||||
{busy ? (
|
||||
<Button
|
||||
size="icon"
|
||||
onClick={() => void handleStop()}
|
||||
title={stopping ? 'Stopping…' : 'Stop the agent'}
|
||||
className={cn(
|
||||
'h-7 w-7 shrink-0 rounded-full transition-all',
|
||||
stopping
|
||||
? 'bg-destructive text-destructive-foreground hover:bg-destructive/90'
|
||||
: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
)}
|
||||
>
|
||||
{stopping ? (
|
||||
<LoaderIcon className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Square className="h-3 w-3 fill-current" />
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="icon"
|
||||
onClick={() => void handleSend()}
|
||||
disabled={!canSend}
|
||||
title="Send"
|
||||
className={cn(
|
||||
'h-7 w-7 shrink-0 rounded-full transition-all',
|
||||
canSend
|
||||
? 'bg-primary text-primary-foreground hover:bg-primary/90'
|
||||
: 'bg-muted text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
<ArrowUp className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
318
apps/x/apps/renderer/src/components/code/code-view.tsx
Normal file
318
apps/x/apps/renderer/src/components/code/code-view.tsx
Normal file
|
|
@ -0,0 +1,318 @@
|
|||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Bot, ChevronDown, ChevronUp, Code2, GitBranch, Terminal as TerminalIcon } from 'lucide-react'
|
||||
import type { CodeSession, CodeSessionStatus } from '@x/shared/src/code-sessions.js'
|
||||
import type { ApprovalPolicy } from '@x/shared/src/code-mode.js'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { useCodeSessions } from './use-code-sessions'
|
||||
import { SessionRail } from './session-rail'
|
||||
import { NewSessionDialog } from './new-session-dialog'
|
||||
import { WorkspacePane } from './workspace-pane'
|
||||
import { TerminalPane } from './terminal-pane'
|
||||
|
||||
const TERMINAL_HEIGHT_STORAGE_KEY = 'x:code-terminal-height'
|
||||
const TERMINAL_MIN_HEIGHT = 120
|
||||
const TERMINAL_MAX_HEIGHT = 600
|
||||
|
||||
function readStoredTerminalHeight(): number {
|
||||
if (typeof window === 'undefined') return 240
|
||||
const raw = Number(window.localStorage.getItem(TERMINAL_HEIGHT_STORAGE_KEY))
|
||||
if (!Number.isFinite(raw) || raw <= 0) return 240
|
||||
return Math.min(TERMINAL_MAX_HEIGHT, Math.max(TERMINAL_MIN_HEIGHT, raw))
|
||||
}
|
||||
|
||||
const AGENT_LABEL: Record<string, string> = { claude: 'Claude Code', codex: 'Codex' }
|
||||
const POLICY_LABEL: Record<ApprovalPolicy, string> = {
|
||||
ask: 'Ask every time',
|
||||
'auto-approve-reads': 'Auto-approve reads',
|
||||
yolo: 'Auto-approve everything',
|
||||
}
|
||||
|
||||
export interface ActiveCodeSession {
|
||||
session: CodeSession
|
||||
status: CodeSessionStatus
|
||||
}
|
||||
|
||||
// The Code section's middle pane: session rail + workspace (diffs/files).
|
||||
// The conversation lives in the RIGHT pane — the assistant chat bound to the
|
||||
// session's run when Rowboat drives, or the direct-drive chat otherwise.
|
||||
// App.tsx learns which via onSessionSelected and renders the right pane.
|
||||
export function CodeView({
|
||||
onSessionSelected,
|
||||
openDiffPath,
|
||||
onDiffOpened,
|
||||
}: {
|
||||
onSessionSelected?: (active: ActiveCodeSession | null) => void
|
||||
// A file path the chat asked to review (clicking a changed file in a tool call).
|
||||
openDiffPath?: string | null
|
||||
onDiffOpened?: () => void
|
||||
}) {
|
||||
const { projects, sessions, statusOf, refresh } = useCodeSessions()
|
||||
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(null)
|
||||
const [newSessionProjectId, setNewSessionProjectId] = useState<string | null>(null)
|
||||
const [deleteTarget, setDeleteTarget] = useState<CodeSession | null>(null)
|
||||
const [terminalOpen, setTerminalOpen] = useState(false)
|
||||
const [terminalHeight, setTerminalHeight] = useState(readStoredTerminalHeight)
|
||||
const dragStateRef = useRef<{ startY: number; startHeight: number } | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
window.localStorage.setItem(TERMINAL_HEIGHT_STORAGE_KEY, String(terminalHeight))
|
||||
}, [terminalHeight])
|
||||
|
||||
const handleTerminalDragStart = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
dragStateRef.current = { startY: e.clientY, startHeight: terminalHeight }
|
||||
const onMove = (event: MouseEvent) => {
|
||||
const drag = dragStateRef.current
|
||||
if (!drag) return
|
||||
// Terminal sits at the bottom: dragging up grows it.
|
||||
const next = drag.startHeight + (drag.startY - event.clientY)
|
||||
setTerminalHeight(Math.min(TERMINAL_MAX_HEIGHT, Math.max(TERMINAL_MIN_HEIGHT, next)))
|
||||
}
|
||||
const onUp = () => {
|
||||
dragStateRef.current = null
|
||||
document.removeEventListener('mousemove', onMove)
|
||||
document.removeEventListener('mouseup', onUp)
|
||||
}
|
||||
document.addEventListener('mousemove', onMove)
|
||||
document.addEventListener('mouseup', onUp)
|
||||
}, [terminalHeight])
|
||||
|
||||
const selectedSession = sessions.find((s) => s.id === selectedSessionId) ?? null
|
||||
const selectedStatus = selectedSession ? statusOf(selectedSession.id) : 'idle'
|
||||
const newSessionProject = projects.find((p) => p.project.id === newSessionProjectId) ?? null
|
||||
|
||||
// Tell App which session (and status) owns the right-hand chat pane.
|
||||
useEffect(() => {
|
||||
onSessionSelected?.(selectedSession ? { session: selectedSession, status: selectedStatus } : null)
|
||||
}, [selectedSession, selectedStatus, onSessionSelected])
|
||||
|
||||
// Leaving the Code section unmounts this view — release the right pane.
|
||||
useEffect(() => {
|
||||
return () => onSessionSelected?.(null)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const handleAddProject = useCallback(async () => {
|
||||
const res = await window.ipc.invoke('dialog:openDirectory', { title: 'Choose a project folder' })
|
||||
const dir = res.path
|
||||
if (!dir) return
|
||||
try {
|
||||
const added = await window.ipc.invoke('codeProject:add', { path: dir })
|
||||
await refresh()
|
||||
setNewSessionProjectId(added.project.id)
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to add project')
|
||||
}
|
||||
}, [refresh])
|
||||
|
||||
const handleRemoveProject = useCallback(async (projectId: string) => {
|
||||
await window.ipc.invoke('codeProject:remove', { projectId })
|
||||
await refresh()
|
||||
}, [refresh])
|
||||
|
||||
const handleSessionCreated = useCallback(async (session: CodeSession) => {
|
||||
await refresh()
|
||||
setSelectedSessionId(session.id)
|
||||
}, [refresh])
|
||||
|
||||
const handleDeleteSession = useCallback(async (session: CodeSession, removeWorktree: boolean) => {
|
||||
try {
|
||||
await window.ipc.invoke('codeSession:delete', {
|
||||
sessionId: session.id,
|
||||
removeWorktree,
|
||||
deleteBranch: removeWorktree,
|
||||
})
|
||||
if (selectedSessionId === session.id) setSelectedSessionId(null)
|
||||
await refresh()
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to delete session')
|
||||
}
|
||||
}, [refresh, selectedSessionId])
|
||||
|
||||
const handleUpdateSession = useCallback(async (patch: { mode?: 'direct' | 'rowboat'; policy?: ApprovalPolicy; agent?: 'claude' | 'codex' }) => {
|
||||
if (!selectedSessionId) return
|
||||
try {
|
||||
await window.ipc.invoke('codeSession:update', { sessionId: selectedSessionId, patch })
|
||||
await refresh()
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to update session')
|
||||
}
|
||||
}, [refresh, selectedSessionId])
|
||||
|
||||
const busy = selectedStatus === 'working' || selectedStatus === 'needs-you'
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0">
|
||||
{/* Session rail */}
|
||||
<div className="w-64 shrink-0 border-r">
|
||||
<SessionRail
|
||||
projects={projects}
|
||||
sessions={sessions}
|
||||
statusOf={statusOf}
|
||||
selectedSessionId={selectedSessionId}
|
||||
onSelectSession={setSelectedSessionId}
|
||||
onAddProject={() => void handleAddProject()}
|
||||
onRemoveProject={(id) => void handleRemoveProject(id)}
|
||||
onNewSession={setNewSessionProjectId}
|
||||
onDeleteSession={setDeleteTarget}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Workspace: session header + diffs/files. The chat is in the right pane. */}
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
{selectedSession ? (
|
||||
<>
|
||||
<div className="flex items-center gap-3 border-b px-4 py-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium">{selectedSession.title}</div>
|
||||
<div className="flex items-center gap-2 text-[11px] text-muted-foreground">
|
||||
<span>{AGENT_LABEL[selectedSession.agent]}</span>
|
||||
<span>·</span>
|
||||
<span className="truncate font-mono" title={selectedSession.cwd}>{selectedSession.cwd}</span>
|
||||
{selectedSession.worktree && !selectedSession.worktree.removedAt && (
|
||||
<span className="flex shrink-0 items-center gap-1 rounded-full bg-muted px-1.5 py-0.5">
|
||||
<GitBranch className="size-3" />
|
||||
{selectedSession.worktree.branch}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs text-muted-foreground">
|
||||
{POLICY_LABEL[selectedSession.policy]}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{(Object.keys(POLICY_LABEL) as ApprovalPolicy[]).map((policy) => (
|
||||
<DropdownMenuItem key={policy} onClick={() => void handleUpdateSession({ policy })}>
|
||||
{POLICY_LABEL[policy]}
|
||||
{selectedSession.policy === policy && <span className="ml-auto">✓</span>}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<label className="flex shrink-0 cursor-pointer items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Bot className="size-3.5" />
|
||||
Rowboat drives
|
||||
<Switch
|
||||
checked={selectedSession.mode === 'rowboat'}
|
||||
disabled={busy}
|
||||
onCheckedChange={(checked) => void handleUpdateSession({ mode: checked ? 'rowboat' : 'direct' })}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1">
|
||||
<WorkspacePane
|
||||
session={selectedSession}
|
||||
status={selectedStatus}
|
||||
openDiffPath={openDiffPath ?? null}
|
||||
onDiffOpened={() => onDiffOpened?.()}
|
||||
onSessionChanged={() => void refresh()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Embedded terminal — a real shell in the session's directory
|
||||
(worktree included). The PTY lives in the main process and
|
||||
survives collapsing this panel. */}
|
||||
<div className="shrink-0 border-t">
|
||||
{terminalOpen && (
|
||||
<div
|
||||
onMouseDown={handleTerminalDragStart}
|
||||
className="h-1 cursor-row-resize bg-transparent transition-colors hover:bg-sidebar-border"
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTerminalOpen((v) => !v)}
|
||||
className="flex w-full items-center gap-1.5 px-3 py-1.5 text-xs text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
|
||||
>
|
||||
<TerminalIcon className="size-3.5" />
|
||||
<span className="font-medium">Terminal</span>
|
||||
{selectedSession.worktree && !selectedSession.worktree.removedAt && (
|
||||
<span className="rounded-full bg-muted px-1.5 py-0.5 text-[10px]">worktree</span>
|
||||
)}
|
||||
<span className="flex-1" />
|
||||
{terminalOpen ? <ChevronDown className="size-3.5" /> : <ChevronUp className="size-3.5" />}
|
||||
</button>
|
||||
{terminalOpen && (
|
||||
<div style={{ height: terminalHeight }}>
|
||||
<TerminalPane
|
||||
key={selectedSession.id}
|
||||
terminalId={selectedSession.id}
|
||||
cwd={selectedSession.cwd}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 text-center">
|
||||
<Code2 className="size-10 text-muted-foreground/40" />
|
||||
<div className="text-sm font-medium">Code with agents</div>
|
||||
<p className="max-w-sm px-6 text-xs text-muted-foreground">
|
||||
Run Claude Code or Codex on your projects — let Rowboat drive them, or talk to them
|
||||
directly. The conversation happens in the chat pane on the right; changes and files
|
||||
show here.
|
||||
</p>
|
||||
{projects.length === 0 ? (
|
||||
<Button size="sm" onClick={() => void handleAddProject()}>Add a project to get started</Button>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">Pick a session on the left, or create a new one.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<NewSessionDialog
|
||||
projectRow={newSessionProject}
|
||||
open={newSessionProjectId !== null}
|
||||
onOpenChange={(open) => { if (!open) setNewSessionProjectId(null) }}
|
||||
onCreated={(session) => void handleSessionCreated(session)}
|
||||
/>
|
||||
|
||||
<AlertDialog open={deleteTarget !== null} onOpenChange={(open) => { if (!open) setDeleteTarget(null) }}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete this session?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
The conversation history will be deleted.
|
||||
{deleteTarget?.worktree && !deleteTarget.worktree.removedAt
|
||||
? ' Its worktree and branch will be removed too — merge back first if you want to keep the changes.'
|
||||
: ''}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => {
|
||||
if (deleteTarget) void handleDeleteSession(deleteTarget, true)
|
||||
setDeleteTarget(null)
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
121
apps/x/apps/renderer/src/components/code/diff-viewer.tsx
Normal file
121
apps/x/apps/renderer/src/components/code/diff-viewer.tsx
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
import { useEffect, useRef, useState } from 'react'
|
||||
import { MergeView, unifiedMergeView } from '@codemirror/merge'
|
||||
import { EditorView } from '@codemirror/view'
|
||||
import { Columns2, FoldVertical, Rows2, UnfoldVertical, X } from 'lucide-react'
|
||||
import { useTheme } from '@/contexts/theme-context'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cmBaseExtensions, cmLanguageFor } from './cm'
|
||||
|
||||
// Read-only diff of one file's working-tree changes vs HEAD, side-by-side or
|
||||
// unified. Content comes from codeSession:fileDiff (old = git show HEAD:path,
|
||||
// new = disk).
|
||||
export function DiffViewer({
|
||||
sessionId,
|
||||
path,
|
||||
onClose,
|
||||
}: {
|
||||
sessionId: string
|
||||
path: string
|
||||
onClose: () => void
|
||||
}) {
|
||||
const { resolvedTheme } = useTheme()
|
||||
const isDark = resolvedTheme === 'dark'
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [mode, setMode] = useState<'split' | 'unified'>('split')
|
||||
// GitHub-style: unchanged regions fold into "⋯ N lines" bars (each clickable
|
||||
// to reveal); "Expand all" rebuilds the view with nothing collapsed.
|
||||
const [collapseUnchanged, setCollapseUnchanged] = useState(true)
|
||||
const [diff, setDiff] = useState<{ oldText: string; newText: string; isBinary: boolean; tooLarge: boolean } | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setDiff(null)
|
||||
setError(null)
|
||||
window.ipc.invoke('codeSession:fileDiff', { sessionId, path })
|
||||
.then((res) => { if (!cancelled) setDiff(res) })
|
||||
.catch((err) => { if (!cancelled) setError(err instanceof Error ? err.message : 'Failed to load diff') })
|
||||
return () => { cancelled = true }
|
||||
}, [sessionId, path])
|
||||
|
||||
useEffect(() => {
|
||||
const parent = containerRef.current
|
||||
if (!parent || !diff || diff.isBinary || diff.tooLarge) return
|
||||
let view: MergeView | EditorView | null = null
|
||||
let cancelled = false
|
||||
|
||||
void cmLanguageFor(path).then((language) => {
|
||||
if (cancelled || !containerRef.current) return
|
||||
const extensions = [...cmBaseExtensions(isDark), ...(language ? [language] : [])]
|
||||
// Same context margins GitHub uses: keep a few lines around each hunk,
|
||||
// only fold stretches long enough to be worth hiding.
|
||||
const collapse = collapseUnchanged ? { margin: 3, minSize: 6 } : undefined
|
||||
if (mode === 'split') {
|
||||
view = new MergeView({
|
||||
a: { doc: diff.oldText, extensions },
|
||||
b: { doc: diff.newText, extensions },
|
||||
parent,
|
||||
gutter: true,
|
||||
...(collapse ? { collapseUnchanged: collapse } : {}),
|
||||
})
|
||||
} else {
|
||||
view = new EditorView({
|
||||
doc: diff.newText,
|
||||
extensions: [
|
||||
...extensions,
|
||||
unifiedMergeView({
|
||||
original: diff.oldText,
|
||||
mergeControls: false,
|
||||
...(collapse ? { collapseUnchanged: collapse } : {}),
|
||||
}),
|
||||
],
|
||||
parent,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
view?.destroy()
|
||||
}
|
||||
}, [diff, mode, isDark, path, collapseUnchanged])
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<div className="flex items-center gap-2 border-b px-3 py-1.5">
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-xs text-foreground/90" title={path}>{path}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs text-muted-foreground"
|
||||
onClick={() => setCollapseUnchanged((c) => !c)}
|
||||
title={collapseUnchanged ? 'Show the whole file' : 'Collapse unchanged regions'}
|
||||
>
|
||||
{collapseUnchanged ? <UnfoldVertical className="size-3.5" /> : <FoldVertical className="size-3.5" />}
|
||||
{collapseUnchanged ? 'Expand all' : 'Collapse'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={() => setMode((m) => (m === 'split' ? 'unified' : 'split'))}
|
||||
title={mode === 'split' ? 'Switch to unified view' : 'Switch to side-by-side view'}
|
||||
>
|
||||
{mode === 'split' ? <Rows2 className="size-3.5" /> : <Columns2 className="size-3.5" />}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2" onClick={onClose} title="Close diff">
|
||||
<X className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-auto">
|
||||
{error && <div className="p-4 text-sm text-destructive">{error}</div>}
|
||||
{!error && !diff && <div className="p-4 text-sm text-muted-foreground">Loading diff…</div>}
|
||||
{diff?.isBinary && <div className="p-4 text-sm text-muted-foreground">Binary file — no text diff.</div>}
|
||||
{diff?.tooLarge && <div className="p-4 text-sm text-muted-foreground">File too large to diff here.</div>}
|
||||
{diff && !diff.isBinary && !diff.tooLarge && (
|
||||
<div ref={containerRef} className="h-full [&_.cm-mergeView]:h-full [&_.cm-editor]:h-full" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
101
apps/x/apps/renderer/src/components/code/file-tree.tsx
Normal file
101
apps/x/apps/renderer/src/components/code/file-tree.tsx
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { ChevronDown, ChevronRight, FileText, Folder } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface TreeEntry {
|
||||
name: string
|
||||
kind: 'file' | 'dir'
|
||||
}
|
||||
|
||||
// Lazy file tree over codeSession:readdir — one directory level per request,
|
||||
// so big folders (node_modules) cost nothing until expanded.
|
||||
export function CodeFileTree({
|
||||
sessionId,
|
||||
selectedPath,
|
||||
onSelectFile,
|
||||
}: {
|
||||
sessionId: string
|
||||
selectedPath: string | null
|
||||
onSelectFile: (relPath: string) => void
|
||||
}) {
|
||||
const [childrenByDir, setChildrenByDir] = useState<Record<string, TreeEntry[]>>({})
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set())
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const loadDir = useCallback(async (relPath: string) => {
|
||||
try {
|
||||
const res = await window.ipc.invoke('codeSession:readdir', { sessionId, relPath })
|
||||
setChildrenByDir((prev) => ({ ...prev, [relPath]: res.entries }))
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to read directory')
|
||||
}
|
||||
}, [sessionId])
|
||||
|
||||
useEffect(() => {
|
||||
setChildrenByDir({})
|
||||
setExpanded(new Set())
|
||||
setError(null)
|
||||
void loadDir('.')
|
||||
}, [loadDir])
|
||||
|
||||
const toggleDir = (relPath: string) => {
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(relPath)) {
|
||||
next.delete(relPath)
|
||||
} else {
|
||||
next.add(relPath)
|
||||
if (!childrenByDir[relPath]) void loadDir(relPath)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const renderDir = (relPath: string, depth: number) => {
|
||||
const entries = childrenByDir[relPath]
|
||||
if (!entries) {
|
||||
return <div className="px-2 py-1 text-xs text-muted-foreground" style={{ paddingLeft: depth * 12 + 8 }}>Loading…</div>
|
||||
}
|
||||
return entries.map((entry) => {
|
||||
const childPath = relPath === '.' ? entry.name : `${relPath}/${entry.name}`
|
||||
if (entry.kind === 'dir') {
|
||||
const isOpen = expanded.has(childPath)
|
||||
return (
|
||||
<div key={childPath}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleDir(childPath)}
|
||||
className="flex w-full items-center gap-1.5 rounded px-2 py-1 text-left text-xs hover:bg-muted"
|
||||
style={{ paddingLeft: depth * 12 + 8 }}
|
||||
>
|
||||
{isOpen ? <ChevronDown className="size-3 shrink-0" /> : <ChevronRight className="size-3 shrink-0" />}
|
||||
<Folder className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">{entry.name}</span>
|
||||
</button>
|
||||
{isOpen && renderDir(childPath, depth + 1)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<button
|
||||
key={childPath}
|
||||
type="button"
|
||||
onClick={() => onSelectFile(childPath)}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-1.5 rounded px-2 py-1 text-left text-xs hover:bg-muted',
|
||||
selectedPath === childPath && 'bg-muted font-medium',
|
||||
)}
|
||||
style={{ paddingLeft: depth * 12 + 22 }}
|
||||
>
|
||||
<FileText className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">{entry.name}</span>
|
||||
</button>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <div className="p-3 text-xs text-destructive">{error}</div>
|
||||
}
|
||||
return <div className="overflow-auto py-1">{renderDir('.', 0)}</div>
|
||||
}
|
||||
70
apps/x/apps/renderer/src/components/code/file-viewer.tsx
Normal file
70
apps/x/apps/renderer/src/components/code/file-viewer.tsx
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import { useEffect, useRef, useState } from 'react'
|
||||
import { EditorView } from '@codemirror/view'
|
||||
import { X } from 'lucide-react'
|
||||
import { useTheme } from '@/contexts/theme-context'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cmBaseExtensions, cmLanguageFor } from './cm'
|
||||
|
||||
// Read-only, syntax-highlighted view of one file in the session directory.
|
||||
export function CodeFileViewer({
|
||||
sessionId,
|
||||
path,
|
||||
onClose,
|
||||
}: {
|
||||
sessionId: string
|
||||
path: string
|
||||
onClose: () => void
|
||||
}) {
|
||||
const { resolvedTheme } = useTheme()
|
||||
const isDark = resolvedTheme === 'dark'
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [file, setFile] = useState<{ content: string; isBinary: boolean; tooLarge: boolean } | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setFile(null)
|
||||
setError(null)
|
||||
window.ipc.invoke('codeSession:readFile', { sessionId, relPath: path })
|
||||
.then((res) => { if (!cancelled) setFile(res) })
|
||||
.catch((err) => { if (!cancelled) setError(err instanceof Error ? err.message : 'Failed to read file') })
|
||||
return () => { cancelled = true }
|
||||
}, [sessionId, path])
|
||||
|
||||
useEffect(() => {
|
||||
const parent = containerRef.current
|
||||
if (!parent || !file || file.isBinary || file.tooLarge) return
|
||||
let view: EditorView | null = null
|
||||
let cancelled = false
|
||||
void cmLanguageFor(path).then((language) => {
|
||||
if (cancelled || !containerRef.current) return
|
||||
view = new EditorView({
|
||||
doc: file.content,
|
||||
extensions: [...cmBaseExtensions(isDark), ...(language ? [language] : [])],
|
||||
parent,
|
||||
})
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
view?.destroy()
|
||||
}
|
||||
}, [file, isDark, path])
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<div className="flex items-center gap-2 border-b px-3 py-1.5">
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-xs text-foreground/90" title={path}>{path}</span>
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2" onClick={onClose} title="Close file">
|
||||
<X className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-auto">
|
||||
{error && <div className="p-4 text-sm text-destructive">{error}</div>}
|
||||
{!error && !file && <div className="p-4 text-sm text-muted-foreground">Loading…</div>}
|
||||
{file?.isBinary && <div className="p-4 text-sm text-muted-foreground">Binary file.</div>}
|
||||
{file?.tooLarge && <div className="p-4 text-sm text-muted-foreground">File too large to preview.</div>}
|
||||
{file && !file.isBinary && !file.tooLarge && <div ref={containerRef} className="h-full [&_.cm-editor]:h-full" />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
315
apps/x/apps/renderer/src/components/code/new-session-dialog.tsx
Normal file
315
apps/x/apps/renderer/src/components/code/new-session-dialog.tsx
Normal file
|
|
@ -0,0 +1,315 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { Bot, GitBranch, Loader2, Terminal } from 'lucide-react'
|
||||
import type { CodeSession, CodeSessionMode } from '@x/shared/src/code-sessions.js'
|
||||
import type { ApprovalPolicy, CodingAgent } from '@x/shared/src/code-mode.js'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import type { ProjectRow } from './use-code-sessions'
|
||||
|
||||
type AgentStatus = { installed: boolean; signedIn: boolean }
|
||||
type ModelOption = { provider: string; model: string }
|
||||
|
||||
const POLICY_LABEL: Record<ApprovalPolicy, string> = {
|
||||
ask: 'Ask every time',
|
||||
'auto-approve-reads': 'Auto-approve reads',
|
||||
yolo: 'Auto-approve everything (YOLO)',
|
||||
}
|
||||
|
||||
// Models the user can pick for Rowboat-mode turns — mirrors the chat
|
||||
// composer's loading: gateway list when signed in, models.json otherwise.
|
||||
async function loadModelOptions(): Promise<ModelOption[]> {
|
||||
try {
|
||||
const oauth = await window.ipc.invoke('oauth:getState', null)
|
||||
const connected = oauth.config?.rowboat?.connected ?? false
|
||||
if (connected) {
|
||||
const listResult = await window.ipc.invoke('models:list', null)
|
||||
const rowboatProvider = (listResult.providers as Array<{ id: string; models?: Array<{ id: string }> }> | undefined)
|
||||
?.find((p) => p.id === 'rowboat')
|
||||
return (rowboatProvider?.models ?? []).map((m) => ({ provider: 'rowboat', model: m.id }))
|
||||
}
|
||||
const result = await window.ipc.invoke('workspace:readFile', { path: 'config/models.json' })
|
||||
const parsed = JSON.parse(result.data)
|
||||
const models: ModelOption[] = []
|
||||
if (parsed?.providers) {
|
||||
for (const [flavor, entry] of Object.entries(parsed.providers)) {
|
||||
const e = entry as Record<string, unknown>
|
||||
const modelList: string[] = Array.isArray(e.models) ? e.models as string[] : []
|
||||
const singleModel = typeof e.model === 'string' ? e.model : ''
|
||||
const allModels = modelList.length > 0 ? modelList : singleModel ? [singleModel] : []
|
||||
for (const model of allModels) {
|
||||
if (model) models.push({ provider: flavor, model })
|
||||
}
|
||||
}
|
||||
}
|
||||
return models
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export function NewSessionDialog({
|
||||
projectRow,
|
||||
open,
|
||||
onOpenChange,
|
||||
onCreated,
|
||||
}: {
|
||||
projectRow: ProjectRow | null
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onCreated: (session: CodeSession) => void
|
||||
}) {
|
||||
const [agentStatus, setAgentStatus] = useState<{ claude: AgentStatus; codex: AgentStatus } | null>(null)
|
||||
const [agent, setAgent] = useState<CodingAgent>('claude')
|
||||
// Rowboat drives by default — direct CLI access is the power-user opt-in.
|
||||
const [mode, setMode] = useState<CodeSessionMode>('rowboat')
|
||||
const [policy, setPolicy] = useState<ApprovalPolicy>('auto-approve-reads')
|
||||
const [isolation, setIsolation] = useState<'in-repo' | 'worktree'>('in-repo')
|
||||
const [title, setTitle] = useState('')
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [modelOptions, setModelOptions] = useState<ModelOption[]>([])
|
||||
// 'default' = let the backend use the configured default model.
|
||||
const [modelKey, setModelKey] = useState('default')
|
||||
|
||||
const git = projectRow?.git
|
||||
const worktreeAvailable = !!git?.isGitRepo && !!git?.hasCommits
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setTitle('')
|
||||
setCreating(false)
|
||||
setIsolation('in-repo')
|
||||
setMode('rowboat')
|
||||
setModelKey('default')
|
||||
void loadModelOptions().then(setModelOptions)
|
||||
void window.ipc.invoke('codeMode:checkAgentStatus', null).then((status) => {
|
||||
setAgentStatus(status)
|
||||
// Default to whichever agent is actually ready.
|
||||
const claudeReady = status.claude.installed && status.claude.signedIn
|
||||
const codexReady = status.codex.installed && status.codex.signedIn
|
||||
if (!claudeReady && codexReady) setAgent('codex')
|
||||
else setAgent('claude')
|
||||
})
|
||||
}, [open])
|
||||
|
||||
const agentReady = (a: CodingAgent): boolean => {
|
||||
if (!agentStatus) return true
|
||||
const s = agentStatus[a]
|
||||
return s.installed && s.signedIn
|
||||
}
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!projectRow) return
|
||||
setCreating(true)
|
||||
try {
|
||||
const picked = modelKey !== 'default'
|
||||
? modelOptions.find((m) => `${m.provider}/${m.model}` === modelKey)
|
||||
: undefined
|
||||
const res = await window.ipc.invoke('codeSession:create', {
|
||||
projectId: projectRow.project.id,
|
||||
title: title.trim() || undefined,
|
||||
agent,
|
||||
mode,
|
||||
policy,
|
||||
isolation,
|
||||
...(picked ? { model: picked.model, provider: picked.provider } : {}),
|
||||
})
|
||||
onOpenChange(false)
|
||||
onCreated(res.session)
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to create session')
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>New coding session</DialogTitle>
|
||||
<DialogDescription>
|
||||
{projectRow ? <span className="font-mono text-xs">{projectRow.project.path}</span> : null}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium">Name (optional)</label>
|
||||
<Input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="e.g. Fix flaky auth tests"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium">Coding agent</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{(['claude', 'codex'] as const).map((a) => {
|
||||
const ready = agentReady(a)
|
||||
return (
|
||||
<button
|
||||
key={a}
|
||||
type="button"
|
||||
disabled={!ready}
|
||||
onClick={() => setAgent(a)}
|
||||
className={cn(
|
||||
'rounded-lg border px-3 py-2 text-left text-sm transition-colors',
|
||||
agent === a ? 'border-foreground bg-muted' : 'hover:bg-muted/60',
|
||||
!ready && 'cursor-not-allowed opacity-50',
|
||||
)}
|
||||
>
|
||||
<div className="font-medium">{a === 'claude' ? 'Claude Code' : 'Codex'}</div>
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
{ready ? 'Ready' : agentStatus?.[a]?.installed ? 'Not signed in' : 'Not installed'}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium">Who drives</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMode('rowboat')}
|
||||
className={cn(
|
||||
'rounded-lg border px-3 py-2 text-left text-sm transition-colors',
|
||||
mode === 'rowboat' ? 'border-foreground bg-muted' : 'hover:bg-muted/60',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 font-medium">
|
||||
<Bot className="size-3.5" />
|
||||
Rowboat
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
Full assistant chat — Rowboat plans, runs the agent, and can use your knowledge.
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMode('direct')}
|
||||
className={cn(
|
||||
'rounded-lg border px-3 py-2 text-left text-sm transition-colors',
|
||||
mode === 'direct' ? 'border-foreground bg-muted' : 'hover:bg-muted/60',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 font-medium">
|
||||
<Terminal className="size-3.5" />
|
||||
Direct
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
Talk straight to the coding agent — no assistant in between.
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium">Where it works</label>
|
||||
<div className="flex flex-col gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsolation('in-repo')}
|
||||
className={cn(
|
||||
'rounded-lg border px-3 py-2 text-left text-sm transition-colors',
|
||||
isolation === 'in-repo' ? 'border-foreground bg-muted' : 'hover:bg-muted/60',
|
||||
)}
|
||||
>
|
||||
<div className="font-medium">Directly in the project</div>
|
||||
<div className="text-[11px] text-muted-foreground">Changes land in your working tree.</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!worktreeAvailable}
|
||||
onClick={() => setIsolation('worktree')}
|
||||
className={cn(
|
||||
'rounded-lg border px-3 py-2 text-left text-sm transition-colors',
|
||||
isolation === 'worktree' ? 'border-foreground bg-muted' : 'hover:bg-muted/60',
|
||||
!worktreeAvailable && 'cursor-not-allowed opacity-50',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 font-medium">
|
||||
<GitBranch className="size-3.5" />
|
||||
Isolated worktree
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
{worktreeAvailable
|
||||
? 'Works on its own branch — safe to run sessions in parallel; merge back when done.'
|
||||
: 'Needs a git repository with at least one commit.'}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium">Approvals</label>
|
||||
<Select value={policy} onValueChange={(v) => setPolicy(v as ApprovalPolicy)}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(Object.keys(POLICY_LABEL) as ApprovalPolicy[]).map((p) => (
|
||||
<SelectItem key={p} value={p}>{POLICY_LABEL[p]}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
How the coding agent's file edits and commands get approved — applies in both modes.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* The model only powers Rowboat's own turns; the coding agent uses its
|
||||
own configured model, so hide this entirely for direct sessions. */}
|
||||
{mode === 'rowboat' && modelOptions.length > 0 && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium">Model</label>
|
||||
<Select value={modelKey} onValueChange={setModelKey}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="default">Default model</SelectItem>
|
||||
{modelOptions.map((m) => {
|
||||
const key = `${m.provider}/${m.model}`
|
||||
return <SelectItem key={key} value={key}>{m.model}</SelectItem>
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Used when Rowboat drives. Fixed once the session is created, like any chat.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
|
||||
<Button onClick={() => void handleCreate()} disabled={creating || !projectRow || !agentReady(agent)}>
|
||||
{creating && <Loader2 className="size-4 animate-spin" />}
|
||||
Create session
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,132 @@
|
|||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// Mirror of ChatSidebar's resize behavior for the direct-mode code chat pane:
|
||||
// same bounds, same drag handle, and the SAME persisted width key — so the
|
||||
// assistant pane and the direct pane stay the same size as the user switches
|
||||
// between session modes.
|
||||
const MIN_WIDTH = 360
|
||||
const MAX_WIDTH = 1600
|
||||
const MIN_MAIN_PANE_WIDTH = 420
|
||||
const MIN_MAIN_PANE_RATIO = 0.3
|
||||
const RIGHT_PANE_WIDTH_STORAGE_KEY = 'x:right-pane-width'
|
||||
|
||||
function clampPaneWidth(width: number, maxWidth: number = MAX_WIDTH): number {
|
||||
const boundedMax = Math.max(0, Math.min(MAX_WIDTH, maxWidth))
|
||||
const boundedMin = Math.min(MIN_WIDTH, boundedMax)
|
||||
return Math.min(boundedMax, Math.max(boundedMin, width))
|
||||
}
|
||||
|
||||
function readStoredWidth(defaultWidth: number): number {
|
||||
const fallback = clampPaneWidth(defaultWidth)
|
||||
if (typeof window === 'undefined') return fallback
|
||||
try {
|
||||
const raw = window.localStorage.getItem(RIGHT_PANE_WIDTH_STORAGE_KEY)
|
||||
if (!raw) return fallback
|
||||
const parsed = Number(raw)
|
||||
if (!Number.isFinite(parsed)) return fallback
|
||||
return clampPaneWidth(parsed)
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
export function ResizableRightPane({
|
||||
defaultWidth = 460,
|
||||
className,
|
||||
children,
|
||||
onActivate,
|
||||
}: {
|
||||
defaultWidth?: number
|
||||
className?: string
|
||||
children: React.ReactNode
|
||||
/** Fired on any mouse-down inside the pane (keyboard-shortcut focus tracking). */
|
||||
onActivate?: () => void
|
||||
}) {
|
||||
const paneRef = useRef<HTMLDivElement>(null)
|
||||
const [width, setWidth] = useState(() => readStoredWidth(defaultWidth))
|
||||
const [isResizing, setIsResizing] = useState(false)
|
||||
const startXRef = useRef(0)
|
||||
const startWidthRef = useRef(0)
|
||||
|
||||
// Never let the pane squeeze the main content below a usable width.
|
||||
const getMaxAllowedWidth = useCallback(() => {
|
||||
if (typeof window === 'undefined') return MAX_WIDTH
|
||||
const paneElement = paneRef.current
|
||||
const splitContainer = paneElement?.parentElement
|
||||
const mainPane = splitContainer?.querySelector<HTMLElement>('[data-slot="sidebar-inset"]')
|
||||
const paneWidth = paneElement?.getBoundingClientRect().width ?? 0
|
||||
const mainPaneWidth = mainPane?.getBoundingClientRect().width ?? 0
|
||||
const splitWidth = paneWidth + mainPaneWidth
|
||||
const fallbackWidth = splitContainer?.clientWidth ?? window.innerWidth
|
||||
const availableSplitWidth = splitWidth > 0 ? splitWidth : fallbackWidth
|
||||
const minMainPaneWidth = Math.min(
|
||||
availableSplitWidth,
|
||||
Math.max(MIN_MAIN_PANE_WIDTH, Math.floor(availableSplitWidth * MIN_MAIN_PANE_RATIO)),
|
||||
)
|
||||
return Math.max(0, availableSplitWidth - minMainPaneWidth)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return
|
||||
try {
|
||||
window.localStorage.setItem(RIGHT_PANE_WIDTH_STORAGE_KEY, String(width))
|
||||
} catch {
|
||||
// keep in-memory width on persistence failure
|
||||
}
|
||||
}, [width])
|
||||
|
||||
useEffect(() => {
|
||||
const clampToAvailableWidth = () => {
|
||||
const maxAllowedWidth = getMaxAllowedWidth()
|
||||
setWidth((prev) => clampPaneWidth(prev, maxAllowedWidth))
|
||||
}
|
||||
clampToAvailableWidth()
|
||||
window.addEventListener('resize', clampToAvailableWidth)
|
||||
return () => window.removeEventListener('resize', clampToAvailableWidth)
|
||||
}, [getMaxAllowedWidth])
|
||||
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
startXRef.current = e.clientX
|
||||
startWidthRef.current = width
|
||||
setIsResizing(true)
|
||||
|
||||
const handleMouseMove = (event: MouseEvent) => {
|
||||
// Pane sits on the right: dragging left grows it.
|
||||
const delta = startXRef.current - event.clientX
|
||||
const maxAllowedWidth = getMaxAllowedWidth()
|
||||
setWidth(clampPaneWidth(startWidthRef.current + delta, maxAllowedWidth))
|
||||
}
|
||||
const handleMouseUp = () => {
|
||||
setIsResizing(false)
|
||||
document.removeEventListener('mousemove', handleMouseMove)
|
||||
document.removeEventListener('mouseup', handleMouseUp)
|
||||
}
|
||||
document.addEventListener('mousemove', handleMouseMove)
|
||||
document.addEventListener('mouseup', handleMouseUp)
|
||||
}, [width, getMaxAllowedWidth])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={paneRef}
|
||||
onMouseDownCapture={onActivate}
|
||||
className={cn(
|
||||
'relative flex min-h-0 min-w-0 shrink-0 flex-col overflow-hidden border-l border-border bg-background',
|
||||
className,
|
||||
)}
|
||||
style={{ width, flex: '0 0 auto' }}
|
||||
>
|
||||
<div
|
||||
onMouseDown={handleMouseDown}
|
||||
className={cn(
|
||||
'absolute inset-y-0 left-0 z-20 w-4 -translate-x-1/2 cursor-col-resize',
|
||||
'after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] after:transition-colors',
|
||||
'hover:after:bg-sidebar-border',
|
||||
isResizing && 'after:bg-primary',
|
||||
)}
|
||||
/>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
179
apps/x/apps/renderer/src/components/code/session-rail.tsx
Normal file
179
apps/x/apps/renderer/src/components/code/session-rail.tsx
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
import { FolderGit2, FolderPlus, MoreHorizontal, Plus, Trash2 } from 'lucide-react'
|
||||
import type { CodeSession, CodeSessionStatus } from '@x/shared/src/code-sessions.js'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import type { ProjectRow } from './use-code-sessions'
|
||||
|
||||
function StatusDot({ status }: { status: CodeSessionStatus }) {
|
||||
if (status === 'needs-you') {
|
||||
return <span className="size-2 shrink-0 animate-pulse rounded-full bg-amber-500" title="Needs your attention" />
|
||||
}
|
||||
if (status === 'working') {
|
||||
return <span className="size-2 shrink-0 animate-pulse rounded-full bg-blue-500" title="Working" />
|
||||
}
|
||||
return <span className="size-2 shrink-0 rounded-full bg-muted-foreground/30" title="Idle" />
|
||||
}
|
||||
|
||||
const AGENT_SHORT: Record<string, string> = { claude: 'Claude', codex: 'Codex' }
|
||||
|
||||
// Left rail: registered projects with their sessions, attention-first.
|
||||
export function SessionRail({
|
||||
projects,
|
||||
sessions,
|
||||
statusOf,
|
||||
selectedSessionId,
|
||||
onSelectSession,
|
||||
onAddProject,
|
||||
onRemoveProject,
|
||||
onNewSession,
|
||||
onDeleteSession,
|
||||
}: {
|
||||
projects: ProjectRow[]
|
||||
sessions: CodeSession[]
|
||||
statusOf: (sessionId: string) => CodeSessionStatus
|
||||
selectedSessionId: string | null
|
||||
onSelectSession: (sessionId: string) => void
|
||||
onAddProject: () => void
|
||||
onRemoveProject: (projectId: string) => void
|
||||
onNewSession: (projectId: string) => void
|
||||
onDeleteSession: (session: CodeSession) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<div className="flex items-center justify-between px-3 py-2">
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Projects</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={onAddProject}>
|
||||
<FolderPlus className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Add a project folder</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-auto px-2 pb-2">
|
||||
{projects.length === 0 && (
|
||||
<div className="flex flex-col items-center gap-3 px-3 py-10 text-center">
|
||||
<FolderGit2 className="size-8 text-muted-foreground/50" />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Add a project folder to start running coding agents on it.
|
||||
</p>
|
||||
<Button size="sm" variant="outline" onClick={onAddProject}>
|
||||
<FolderPlus className="size-3.5" />
|
||||
Add project
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{projects.map(({ project }) => {
|
||||
const projectSessions = sessions.filter((s) => s.projectId === project.id)
|
||||
return (
|
||||
<div key={project.id} className="mb-3">
|
||||
<div className="group flex items-center gap-1.5 px-1 py-1">
|
||||
{/* Deliberate hover delay — the full path is reference info,
|
||||
not something that should pop up on a passing cursor. */}
|
||||
<Tooltip delayDuration={1000}>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="flex min-w-0 flex-1 items-center gap-1.5">
|
||||
<FolderGit2 className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1 truncate text-xs font-medium">
|
||||
{project.name}
|
||||
</span>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" className="max-w-[420px] break-all font-mono text-xs">
|
||||
{project.path}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 opacity-0 transition-opacity group-hover:opacity-100"
|
||||
onClick={() => onNewSession(project.id)}
|
||||
title="New session"
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 opacity-0 transition-opacity group-hover:opacity-100"
|
||||
>
|
||||
<MoreHorizontal className="size-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuItem onClick={() => onRemoveProject(project.id)}>
|
||||
<Trash2 className="size-4" />
|
||||
Remove project
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
{projectSessions.length === 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onNewSession(project.id)}
|
||||
className="ml-5 flex items-center gap-1.5 rounded px-2 py-1 text-xs text-muted-foreground hover:bg-muted"
|
||||
>
|
||||
<Plus className="size-3" />
|
||||
New session
|
||||
</button>
|
||||
) : (
|
||||
projectSessions.map((session) => {
|
||||
const status = statusOf(session.id)
|
||||
return (
|
||||
<div
|
||||
key={session.id}
|
||||
className={cn(
|
||||
'group ml-3 flex cursor-pointer items-center gap-2 rounded-lg px-2 py-1.5',
|
||||
selectedSessionId === session.id ? 'bg-muted' : 'hover:bg-muted/60',
|
||||
)}
|
||||
onClick={() => onSelectSession(session.id)}
|
||||
>
|
||||
<StatusDot status={status} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-xs">{session.title}</div>
|
||||
<div className="truncate text-[10px] text-muted-foreground">
|
||||
{AGENT_SHORT[session.agent]}
|
||||
{session.mode === 'rowboat' ? ' · Rowboat drives' : ''}
|
||||
{session.worktree && !session.worktree.removedAt ? ' · worktree' : ''}
|
||||
</div>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 shrink-0 p-0 opacity-0 transition-opacity group-hover:opacity-100"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<MoreHorizontal className="size-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" onClick={(e) => e.stopPropagation()}>
|
||||
<DropdownMenuItem onClick={() => onDeleteSession(session)}>
|
||||
<Trash2 className="size-4" />
|
||||
Delete session
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
110
apps/x/apps/renderer/src/components/code/terminal-pane.tsx
Normal file
110
apps/x/apps/renderer/src/components/code/terminal-pane.tsx
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
import { useEffect, useRef } from 'react'
|
||||
import { Terminal } from '@xterm/xterm'
|
||||
import { FitAddon } from '@xterm/addon-fit'
|
||||
import '@xterm/xterm/css/xterm.css'
|
||||
import { useTheme } from '@/contexts/theme-context'
|
||||
|
||||
// xterm color schemes tuned to the app's light/dark backgrounds.
|
||||
const DARK_THEME = {
|
||||
background: '#1b1b1f',
|
||||
foreground: '#d4d4d8',
|
||||
cursor: '#d4d4d8',
|
||||
selectionBackground: 'rgba(120, 140, 255, 0.3)',
|
||||
}
|
||||
const LIGHT_THEME = {
|
||||
background: '#ffffff',
|
||||
foreground: '#27272a',
|
||||
cursor: '#27272a',
|
||||
selectionBackground: 'rgba(60, 90, 220, 0.2)',
|
||||
}
|
||||
|
||||
// One embedded terminal view, attached to a per-session PTY in the main
|
||||
// process. The PTY outlives this component (collapse/switch just detaches);
|
||||
// on mount we re-attach and repaint from the backlog the main process keeps.
|
||||
export function TerminalPane({ terminalId, cwd }: { terminalId: string; cwd: string }) {
|
||||
const { resolvedTheme } = useTheme()
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const termRef = useRef<Terminal | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current
|
||||
if (!container) return
|
||||
|
||||
const term = new Terminal({
|
||||
fontSize: 12,
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
|
||||
cursorBlink: true,
|
||||
scrollback: 5000,
|
||||
theme: resolvedTheme === 'dark' ? DARK_THEME : LIGHT_THEME,
|
||||
})
|
||||
const fit = new FitAddon()
|
||||
term.loadAddon(fit)
|
||||
term.open(container)
|
||||
fit.fit()
|
||||
termRef.current = term
|
||||
|
||||
let disposed = false
|
||||
|
||||
// Attach (or spawn) the PTY at the current size, then repaint history.
|
||||
void window.ipc.invoke('terminal:ensure', {
|
||||
id: terminalId,
|
||||
cwd,
|
||||
cols: term.cols,
|
||||
rows: term.rows,
|
||||
}).then(({ backlog }) => {
|
||||
if (disposed) return
|
||||
if (backlog) term.write(backlog)
|
||||
term.focus()
|
||||
})
|
||||
|
||||
const dataDisposable = term.onData((data) => {
|
||||
void window.ipc.invoke('terminal:input', { id: terminalId, data })
|
||||
})
|
||||
|
||||
const offData = window.ipc.on('terminal:data', (payload) => {
|
||||
if (payload.id === terminalId) term.write(payload.data)
|
||||
})
|
||||
const offExit = window.ipc.on('terminal:exit', (payload) => {
|
||||
if (payload.id !== terminalId) return
|
||||
term.write(`\r\n\x1b[2m[process exited with code ${payload.exitCode} — press Enter to restart]\x1b[0m\r\n`)
|
||||
})
|
||||
|
||||
// Restart the shell on Enter after it exited (ensure() respawns dead PTYs).
|
||||
const keyDisposable = term.onKey(({ domEvent }) => {
|
||||
if (domEvent.key !== 'Enter') return
|
||||
void window.ipc.invoke('terminal:ensure', {
|
||||
id: terminalId,
|
||||
cwd,
|
||||
cols: term.cols,
|
||||
rows: term.rows,
|
||||
})
|
||||
})
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
if (container.clientHeight === 0) return
|
||||
fit.fit()
|
||||
void window.ipc.invoke('terminal:resize', { id: terminalId, cols: term.cols, rows: term.rows })
|
||||
})
|
||||
resizeObserver.observe(container)
|
||||
|
||||
return () => {
|
||||
disposed = true
|
||||
resizeObserver.disconnect()
|
||||
offData()
|
||||
offExit()
|
||||
dataDisposable.dispose()
|
||||
keyDisposable.dispose()
|
||||
term.dispose()
|
||||
termRef.current = null
|
||||
}
|
||||
// The PTY is keyed by terminalId; cwd changes (worktree cleanup) respawn via ensure.
|
||||
}, [terminalId, cwd])
|
||||
|
||||
// Live theme switches restyle the existing terminal without a respawn.
|
||||
useEffect(() => {
|
||||
const term = termRef.current
|
||||
if (term) term.options.theme = resolvedTheme === 'dark' ? DARK_THEME : LIGHT_THEME
|
||||
}, [resolvedTheme])
|
||||
|
||||
return <div ref={containerRef} className="h-full w-full overflow-hidden px-2 pt-1" />
|
||||
}
|
||||
473
apps/x/apps/renderer/src/components/code/use-code-chat.ts
Normal file
473
apps/x/apps/renderer/src/components/code/use-code-chat.ts
Normal file
|
|
@ -0,0 +1,473 @@
|
|||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import type z from 'zod'
|
||||
import type { RunEvent, ToolPermissionRequestEvent, AskHumanRequestEvent } from '@x/shared/src/runs.js'
|
||||
import type { CodeRunEvent, PermissionAsk, PermissionDecision } from '@x/shared/src/code-mode.js'
|
||||
import type { CodeSession } from '@x/shared/src/code-sessions.js'
|
||||
import {
|
||||
type ChatMessage,
|
||||
type ErrorMessage,
|
||||
type ToolCall,
|
||||
normalizeToolInput,
|
||||
} from '@/lib/chat-conversation'
|
||||
|
||||
// A direct-drive coding turn: the structural ACP events (tool calls, plan,
|
||||
// resolved permissions) grouped under one turn id. The agent's prose is NOT
|
||||
// part of the turn — it streams via liveText and lands as an assistant
|
||||
// ChatMessage, so live rendering and JSONL replay converge on the same shape.
|
||||
export interface DirectTurn {
|
||||
kind: 'direct-turn'
|
||||
id: string
|
||||
events: CodeRunEvent[]
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
export type CodeChatItem = ChatMessage | ToolCall | ErrorMessage | DirectTurn
|
||||
|
||||
export const isDirectTurn = (item: CodeChatItem): item is DirectTurn =>
|
||||
'kind' in item && (item as DirectTurn).kind === 'direct-turn'
|
||||
|
||||
// Narrowing guards over the widened item union (the chat-conversation guards
|
||||
// only accept ConversationItem).
|
||||
export const isChatToolCall = (item: CodeChatItem): item is ToolCall => 'name' in item
|
||||
export const isChatErrorMessage = (item: CodeChatItem): item is ErrorMessage =>
|
||||
'kind' in item && (item as ErrorMessage).kind === 'error'
|
||||
export const isChatMessageItem = (item: CodeChatItem): item is ChatMessage => 'role' in item
|
||||
|
||||
export interface PendingCodePermission {
|
||||
requestId: string
|
||||
ask: PermissionAsk
|
||||
toolCallId: string
|
||||
}
|
||||
|
||||
const DIRECT_PREFIX = 'direct-'
|
||||
const STRUCTURAL_EVENTS = new Set(['tool_call', 'tool_call_update', 'plan', 'permission'])
|
||||
|
||||
function messageText(content: unknown): string {
|
||||
if (typeof content === 'string') return content
|
||||
if (Array.isArray(content)) {
|
||||
return (content as Array<{ type: string; text?: string }>)
|
||||
.filter((p) => p.type === 'text')
|
||||
.map((p) => p.text ?? '')
|
||||
.join('')
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
// Conversation state for one coding session, fed by the run JSONL (history)
|
||||
// and the live runs:events stream. Handles both modes: direct turns arrive as
|
||||
// code-run-events with a `direct-` toolCallId; Rowboat turns arrive as the
|
||||
// usual LLM message/tool events (incl. code_agent_run blocks).
|
||||
export function useCodeChat(session: CodeSession | null) {
|
||||
const sessionId = session?.id ?? null
|
||||
const [items, setItems] = useState<CodeChatItem[]>([])
|
||||
const [liveText, setLiveText] = useState('')
|
||||
const [isProcessing, setIsProcessing] = useState(false)
|
||||
const [pendingPermission, setPendingPermission] = useState<PendingCodePermission | null>(null)
|
||||
// Rowboat-mode copilot gates, same as the main chat: pre-tool-call permission
|
||||
// requests and ask-human questions. Keyed by toolCallId.
|
||||
const [pendingToolPermissions, setPendingToolPermissions] = useState<Map<string, z.infer<typeof ToolPermissionRequestEvent>>>(new Map())
|
||||
const [pendingAskHumans, setPendingAskHumans] = useState<Map<string, z.infer<typeof AskHumanRequestEvent>>>(new Map())
|
||||
const [loading, setLoading] = useState(false)
|
||||
const seenMessageIdsRef = useRef<Set<string>>(new Set())
|
||||
|
||||
const applyCodeRunEvent = useCallback((toolCallId: string, event: CodeRunEvent) => {
|
||||
if (toolCallId.startsWith(DIRECT_PREFIX)) {
|
||||
if (!STRUCTURAL_EVENTS.has(event.type)) return
|
||||
setItems((prev) => {
|
||||
const at = prev.findIndex((item) => isDirectTurn(item) && item.id === toolCallId)
|
||||
if (at >= 0) {
|
||||
const turn = prev[at] as DirectTurn
|
||||
const next = [...prev]
|
||||
next[at] = { ...turn, events: [...turn.events, event] }
|
||||
return next
|
||||
}
|
||||
return [...prev, { kind: 'direct-turn', id: toolCallId, events: [event], timestamp: Date.now() }]
|
||||
})
|
||||
return
|
||||
}
|
||||
// Rowboat mode: attach to the code_agent_run tool call block.
|
||||
setItems((prev) => prev.map((item) => {
|
||||
if (isChatToolCall(item) && item.id === toolCallId) {
|
||||
return { ...item, codeRunEvents: [...(item.codeRunEvents ?? []), event] }
|
||||
}
|
||||
return item
|
||||
}))
|
||||
}, [])
|
||||
|
||||
// Load history from the run log whenever the session changes.
|
||||
useEffect(() => {
|
||||
if (!sessionId) {
|
||||
setItems([])
|
||||
setLiveText('')
|
||||
setPendingPermission(null)
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
setItems([])
|
||||
setLiveText('')
|
||||
setPendingPermission(null)
|
||||
setPendingToolPermissions(new Map())
|
||||
setPendingAskHumans(new Map())
|
||||
seenMessageIdsRef.current = new Set()
|
||||
|
||||
void window.ipc.invoke('runs:fetch', { runId: sessionId }).then((run) => {
|
||||
if (cancelled) return
|
||||
const loaded: CodeChatItem[] = []
|
||||
const toolCallMap = new Map<string, ToolCall>()
|
||||
const turnMap = new Map<string, DirectTurn>()
|
||||
// Rebuild copilot gates still waiting on the user (request without a
|
||||
// matching response in the log) so reopening a blocked session shows them.
|
||||
const toolPerms = new Map<string, z.infer<typeof ToolPermissionRequestEvent>>()
|
||||
const askHumans = new Map<string, z.infer<typeof AskHumanRequestEvent>>()
|
||||
|
||||
for (const event of run.log as z.infer<typeof RunEvent>[]) {
|
||||
const ts = event.ts ? new Date(event.ts).getTime() : Date.now()
|
||||
switch (event.type) {
|
||||
case 'message': {
|
||||
const msg = event.message
|
||||
if (msg.role === 'user' || msg.role === 'assistant') {
|
||||
const text = messageText(msg.content)
|
||||
if (msg.role === 'assistant' && Array.isArray(msg.content)) {
|
||||
for (const part of msg.content as Array<{ type: string; toolCallId?: string; toolName?: string; arguments?: unknown }>) {
|
||||
if (part.type === 'tool-call' && part.toolCallId && part.toolName) {
|
||||
const toolCall: ToolCall = {
|
||||
id: part.toolCallId,
|
||||
name: part.toolName,
|
||||
input: normalizeToolInput(part.arguments as ToolCall['input']),
|
||||
status: 'pending',
|
||||
timestamp: ts,
|
||||
}
|
||||
toolCallMap.set(toolCall.id, toolCall)
|
||||
loaded.push(toolCall)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (text.trim()) {
|
||||
seenMessageIdsRef.current.add(event.messageId)
|
||||
loaded.push({ id: event.messageId, role: msg.role, content: text, timestamp: ts })
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'tool-invocation': {
|
||||
const existing = event.toolCallId ? toolCallMap.get(event.toolCallId) : null
|
||||
if (existing) {
|
||||
existing.input = normalizeToolInput(event.input)
|
||||
existing.status = 'running'
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'tool-result': {
|
||||
const existing = event.toolCallId ? toolCallMap.get(event.toolCallId) : null
|
||||
if (existing) {
|
||||
existing.result = event.result as ToolCall['result']
|
||||
existing.status = 'completed'
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'code-run-event': {
|
||||
if (event.toolCallId.startsWith(DIRECT_PREFIX)) {
|
||||
if (!STRUCTURAL_EVENTS.has(event.event.type)) break
|
||||
let turn = turnMap.get(event.toolCallId)
|
||||
if (!turn) {
|
||||
turn = { kind: 'direct-turn', id: event.toolCallId, events: [], timestamp: ts }
|
||||
turnMap.set(event.toolCallId, turn)
|
||||
loaded.push(turn)
|
||||
}
|
||||
turn.events.push(event.event)
|
||||
} else {
|
||||
const existing = toolCallMap.get(event.toolCallId)
|
||||
if (existing) existing.codeRunEvents = [...(existing.codeRunEvents ?? []), event.event]
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'tool-permission-request':
|
||||
toolPerms.set(event.toolCall.toolCallId, event)
|
||||
break
|
||||
case 'tool-permission-response':
|
||||
toolPerms.delete(event.toolCallId)
|
||||
break
|
||||
case 'ask-human-request':
|
||||
askHumans.set(event.toolCallId, event)
|
||||
break
|
||||
case 'ask-human-response':
|
||||
askHumans.delete(event.toolCallId)
|
||||
break
|
||||
case 'run-stopped':
|
||||
toolPerms.clear()
|
||||
askHumans.clear()
|
||||
break
|
||||
case 'error':
|
||||
loaded.push({ id: `error-${loaded.length}`, kind: 'error', message: event.error, timestamp: ts })
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
setItems(loaded)
|
||||
setPendingToolPermissions(toolPerms)
|
||||
setPendingAskHumans(askHumans)
|
||||
}).catch(() => {
|
||||
// Run log unreadable — show an empty conversation rather than crashing.
|
||||
}).finally(() => {
|
||||
if (!cancelled) setLoading(false)
|
||||
})
|
||||
|
||||
return () => { cancelled = true }
|
||||
}, [sessionId])
|
||||
|
||||
// Live event stream.
|
||||
useEffect(() => {
|
||||
if (!sessionId) return
|
||||
// runs:events is schema-less on the wire (req: z.null()) — cast like App.tsx does.
|
||||
return window.ipc.on('runs:events', ((raw: unknown) => {
|
||||
const event = raw as z.infer<typeof RunEvent>
|
||||
if (event.runId !== sessionId) return
|
||||
switch (event.type) {
|
||||
case 'run-processing-start':
|
||||
setIsProcessing(true)
|
||||
break
|
||||
case 'run-processing-end':
|
||||
setIsProcessing(false)
|
||||
setPendingPermission(null)
|
||||
// Anything still streaming that never landed as a message (e.g. the
|
||||
// turn errored) is flushed so the text isn't lost.
|
||||
setLiveText((text) => {
|
||||
if (text.trim()) {
|
||||
setItems((prev) => [...prev, {
|
||||
id: `assistant-flush-${Date.now()}`,
|
||||
role: 'assistant',
|
||||
content: text,
|
||||
timestamp: Date.now(),
|
||||
}])
|
||||
}
|
||||
return ''
|
||||
})
|
||||
break
|
||||
case 'run-stopped':
|
||||
setIsProcessing(false)
|
||||
setPendingPermission(null)
|
||||
setPendingToolPermissions(new Map())
|
||||
setPendingAskHumans(new Map())
|
||||
break
|
||||
case 'tool-permission-request':
|
||||
setPendingToolPermissions((prev) => new Map(prev).set(event.toolCall.toolCallId, event))
|
||||
break
|
||||
case 'tool-permission-response':
|
||||
setPendingToolPermissions((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.delete(event.toolCallId)
|
||||
return next
|
||||
})
|
||||
break
|
||||
case 'ask-human-request':
|
||||
setPendingAskHumans((prev) => new Map(prev).set(event.toolCallId, event))
|
||||
break
|
||||
case 'ask-human-response':
|
||||
setPendingAskHumans((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.delete(event.toolCallId)
|
||||
return next
|
||||
})
|
||||
break
|
||||
case 'message': {
|
||||
const msg = event.message
|
||||
if (msg.role !== 'user' && msg.role !== 'assistant') break
|
||||
if (seenMessageIdsRef.current.has(event.messageId)) break
|
||||
const text = messageText(msg.content)
|
||||
if (msg.role === 'assistant' && Array.isArray(msg.content)) {
|
||||
for (const part of msg.content as Array<{ type: string; toolCallId?: string; toolName?: string; arguments?: unknown }>) {
|
||||
if (part.type === 'tool-call' && part.toolCallId && part.toolName) {
|
||||
const toolCall: ToolCall = {
|
||||
id: part.toolCallId,
|
||||
name: part.toolName,
|
||||
input: normalizeToolInput(part.arguments as ToolCall['input']),
|
||||
status: 'running',
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
setItems((prev) => (prev.some((i) => isChatToolCall(i) && i.id === toolCall.id) ? prev : [...prev, toolCall]))
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!text.trim()) break
|
||||
seenMessageIdsRef.current.add(event.messageId)
|
||||
const chatMessage: ChatMessage = {
|
||||
id: event.messageId,
|
||||
role: msg.role,
|
||||
content: text.replace(/<\/?voice>/g, ''),
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
if (msg.role === 'assistant') setLiveText('')
|
||||
setItems((prev) => {
|
||||
// Replace the optimistic local echo of this user message if present.
|
||||
if (msg.role === 'user') {
|
||||
const at = prev.findIndex((item) =>
|
||||
'role' in item && item.role === 'user' && item.id.startsWith('local-') && item.content === text)
|
||||
if (at >= 0) {
|
||||
const next = [...prev]
|
||||
next[at] = chatMessage
|
||||
return next
|
||||
}
|
||||
}
|
||||
return [...prev, chatMessage]
|
||||
})
|
||||
break
|
||||
}
|
||||
case 'llm-stream-event': {
|
||||
// Rowboat mode streaming text.
|
||||
const llmEvent = event.event as { type: string; delta?: string; toolCallId?: string; toolName?: string; input?: unknown }
|
||||
setIsProcessing(true)
|
||||
if (llmEvent.type === 'text-delta' && llmEvent.delta) {
|
||||
setLiveText((prev) => prev + llmEvent.delta)
|
||||
} else if (llmEvent.type === 'tool-call' && llmEvent.toolCallId) {
|
||||
const toolCall: ToolCall = {
|
||||
id: llmEvent.toolCallId,
|
||||
name: llmEvent.toolName || 'tool',
|
||||
input: normalizeToolInput(llmEvent.input as ToolCall['input']),
|
||||
status: 'running',
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
setItems((prev) => (prev.some((i) => isChatToolCall(i) && i.id === toolCall.id) ? prev : [...prev, toolCall]))
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'tool-invocation':
|
||||
setItems((prev) => prev.map((item) => (
|
||||
isChatToolCall(item) && item.id === event.toolCallId
|
||||
? { ...item, input: normalizeToolInput(event.input), status: 'running' as const }
|
||||
: item
|
||||
)))
|
||||
break
|
||||
case 'tool-result':
|
||||
setItems((prev) => prev.map((item) => (
|
||||
isChatToolCall(item) && item.id === event.toolCallId
|
||||
? { ...item, result: event.result as ToolCall['result'], status: 'completed' as const, pendingCodePermission: null }
|
||||
: item
|
||||
)))
|
||||
break
|
||||
case 'code-run-event': {
|
||||
setIsProcessing(true)
|
||||
if (event.event.type === 'message' && event.event.role === 'agent' && event.toolCallId.startsWith(DIRECT_PREFIX)) {
|
||||
const text = event.event.text
|
||||
setLiveText((prev) => prev + text)
|
||||
}
|
||||
if (event.event.type === 'permission') {
|
||||
setPendingPermission(null)
|
||||
}
|
||||
applyCodeRunEvent(event.toolCallId, event.event)
|
||||
break
|
||||
}
|
||||
case 'code-run-permission-request':
|
||||
setPendingPermission({ requestId: event.requestId, ask: event.ask, toolCallId: event.toolCallId })
|
||||
break
|
||||
case 'error':
|
||||
setItems((prev) => [...prev, {
|
||||
id: `error-${Date.now()}`,
|
||||
kind: 'error',
|
||||
message: event.error,
|
||||
timestamp: Date.now(),
|
||||
}])
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
}) as unknown as (event: null) => void)
|
||||
}, [sessionId, applyCodeRunEvent])
|
||||
|
||||
const send = useCallback(async (text: string): Promise<{ ok: boolean; error?: string }> => {
|
||||
if (!session) return { ok: false, error: 'No session selected' }
|
||||
const trimmed = text.trim()
|
||||
if (!trimmed) return { ok: false }
|
||||
// Optimistic echo, replaced by the persisted event when it arrives.
|
||||
setItems((prev) => [...prev, {
|
||||
id: `local-${Date.now()}`,
|
||||
role: 'user',
|
||||
content: trimmed,
|
||||
timestamp: Date.now(),
|
||||
}])
|
||||
setIsProcessing(true)
|
||||
try {
|
||||
if (session.mode === 'direct') {
|
||||
const res = await window.ipc.invoke('codeSession:sendMessage', { sessionId: session.id, text: trimmed })
|
||||
if (!res.accepted) {
|
||||
setIsProcessing(false)
|
||||
return { ok: false, error: res.error ?? 'The session is busy.' }
|
||||
}
|
||||
} else {
|
||||
await window.ipc.invoke('runs:createMessage', {
|
||||
runId: session.id,
|
||||
message: trimmed,
|
||||
codeMode: session.agent,
|
||||
codeCwd: session.cwd,
|
||||
codePolicy: session.policy,
|
||||
})
|
||||
}
|
||||
return { ok: true }
|
||||
} catch (err) {
|
||||
setIsProcessing(false)
|
||||
return { ok: false, error: err instanceof Error ? err.message : 'Failed to send message' }
|
||||
}
|
||||
}, [session])
|
||||
|
||||
const stop = useCallback(async () => {
|
||||
if (!sessionId) return
|
||||
await window.ipc.invoke('codeSession:stop', { sessionId })
|
||||
}, [sessionId])
|
||||
|
||||
const resolvePermission = useCallback(async (decision: PermissionDecision) => {
|
||||
if (!pendingPermission) return
|
||||
setPendingPermission(null)
|
||||
await window.ipc.invoke('codeRun:resolvePermission', {
|
||||
requestId: pendingPermission.requestId,
|
||||
decision,
|
||||
})
|
||||
}, [pendingPermission])
|
||||
|
||||
// Rowboat-mode copilot gates — same IPC the main chat uses.
|
||||
const respondToToolPermission = useCallback(async (
|
||||
toolCallId: string,
|
||||
subflow: string[],
|
||||
response: 'approve' | 'deny',
|
||||
scope?: 'once' | 'session' | 'always',
|
||||
) => {
|
||||
if (!sessionId) return
|
||||
setPendingToolPermissions((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.delete(toolCallId)
|
||||
return next
|
||||
})
|
||||
await window.ipc.invoke('runs:authorizePermission', {
|
||||
runId: sessionId,
|
||||
authorization: { subflow, toolCallId, response, scope },
|
||||
})
|
||||
}, [sessionId])
|
||||
|
||||
const respondToAskHuman = useCallback(async (toolCallId: string, subflow: string[], response: string) => {
|
||||
if (!sessionId) return
|
||||
setPendingAskHumans((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.delete(toolCallId)
|
||||
return next
|
||||
})
|
||||
await window.ipc.invoke('runs:provideHumanInput', {
|
||||
runId: sessionId,
|
||||
reply: { subflow, toolCallId, response },
|
||||
})
|
||||
}, [sessionId])
|
||||
|
||||
return {
|
||||
items,
|
||||
liveText,
|
||||
isProcessing,
|
||||
pendingPermission,
|
||||
pendingToolPermissions,
|
||||
pendingAskHumans,
|
||||
loading,
|
||||
send,
|
||||
stop,
|
||||
resolvePermission,
|
||||
respondToToolPermission,
|
||||
respondToAskHuman,
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
import { useCallback, useEffect, useState } from 'react'
|
||||
import type { CodeProject, CodeSession, CodeSessionStatus, GitRepoInfo } from '@x/shared/src/code-sessions.js'
|
||||
|
||||
export interface ProjectRow {
|
||||
project: CodeProject
|
||||
git: GitRepoInfo
|
||||
}
|
||||
|
||||
const STATUS_RANK: Record<CodeSessionStatus, number> = {
|
||||
'needs-you': 0,
|
||||
working: 1,
|
||||
idle: 2,
|
||||
}
|
||||
|
||||
// Projects + sessions + live statuses for the Code section. Statuses stream
|
||||
// over `codeSession:status` (pushed by the main-process tracker); the lists
|
||||
// load on demand and on session lifecycle changes.
|
||||
export function useCodeSessions() {
|
||||
const [projects, setProjects] = useState<ProjectRow[]>([])
|
||||
const [sessions, setSessions] = useState<CodeSession[]>([])
|
||||
const [statuses, setStatuses] = useState<Record<string, CodeSessionStatus>>({})
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const [projectsRes, sessionsRes] = await Promise.all([
|
||||
window.ipc.invoke('codeProject:list', null),
|
||||
window.ipc.invoke('codeSession:list', null),
|
||||
])
|
||||
setProjects(projectsRes.projects)
|
||||
setSessions(sessionsRes.sessions)
|
||||
setStatuses((prev) => ({ ...sessionsRes.statuses, ...prev }))
|
||||
} finally {
|
||||
setLoaded(true)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void refresh()
|
||||
}, [refresh])
|
||||
|
||||
useEffect(() => {
|
||||
return window.ipc.on('codeSession:status', ({ sessionId, status }) => {
|
||||
setStatuses((prev) => (prev[sessionId] === status ? prev : { ...prev, [sessionId]: status }))
|
||||
// Turn boundaries bump lastActivityAt — refresh ordering when one ends.
|
||||
if (status === 'idle') {
|
||||
void window.ipc.invoke('codeSession:list', null).then((res) => setSessions(res.sessions))
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
const statusOf = useCallback(
|
||||
(sessionId: string): CodeSessionStatus => statuses[sessionId] ?? 'idle',
|
||||
[statuses],
|
||||
)
|
||||
|
||||
const sortedSessions = [...sessions].sort((a, b) => {
|
||||
const rank = STATUS_RANK[statusOf(a.id)] - STATUS_RANK[statusOf(b.id)]
|
||||
if (rank !== 0) return rank
|
||||
return (b.lastActivityAt ?? b.createdAt).localeCompare(a.lastActivityAt ?? a.createdAt)
|
||||
})
|
||||
|
||||
return {
|
||||
projects,
|
||||
sessions: sortedSessions,
|
||||
statuses,
|
||||
statusOf,
|
||||
loaded,
|
||||
refresh,
|
||||
setSessions,
|
||||
}
|
||||
}
|
||||
254
apps/x/apps/renderer/src/components/code/workspace-pane.tsx
Normal file
254
apps/x/apps/renderer/src/components/code/workspace-pane.tsx
Normal file
|
|
@ -0,0 +1,254 @@
|
|||
import { useCallback, useEffect, useState } from 'react'
|
||||
import {
|
||||
FileDiff,
|
||||
FilePlus2,
|
||||
FileX2,
|
||||
FileEdit,
|
||||
GitBranch,
|
||||
GitMerge,
|
||||
MoreHorizontal,
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
} from 'lucide-react'
|
||||
import type { CodeSession, CodeSessionStatus, GitStatusFile } from '@x/shared/src/code-sessions.js'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { CodeFileTree } from './file-tree'
|
||||
import { CodeFileViewer } from './file-viewer'
|
||||
import { DiffViewer } from './diff-viewer'
|
||||
|
||||
type GitStatus = {
|
||||
isRepo: boolean
|
||||
branch: string | null
|
||||
hasCommits: boolean
|
||||
files: GitStatusFile[]
|
||||
}
|
||||
|
||||
const STATE_ICON: Record<GitStatusFile['state'], typeof FileEdit> = {
|
||||
modified: FileEdit,
|
||||
added: FilePlus2,
|
||||
untracked: FilePlus2,
|
||||
deleted: FileX2,
|
||||
renamed: FileEdit,
|
||||
}
|
||||
|
||||
// Right pane of a coding session: a diff reviewer first (Changes), a code
|
||||
// browser second (Files). Read-only in v1 by design.
|
||||
export function WorkspacePane({
|
||||
session,
|
||||
status,
|
||||
openDiffPath,
|
||||
onDiffOpened,
|
||||
onSessionChanged,
|
||||
}: {
|
||||
session: CodeSession
|
||||
status: CodeSessionStatus
|
||||
// A file path requested from the chat (clicking a changed file in a tool call).
|
||||
openDiffPath: string | null
|
||||
onDiffOpened: () => void
|
||||
onSessionChanged: () => void
|
||||
}) {
|
||||
const [tab, setTab] = useState<'changes' | 'files'>('changes')
|
||||
const [gitStatus, setGitStatus] = useState<GitStatus | null>(null)
|
||||
const [diffPath, setDiffPath] = useState<string | null>(null)
|
||||
const [filePath, setFilePath] = useState<string | null>(null)
|
||||
const [merging, setMerging] = useState(false)
|
||||
|
||||
const refreshStatus = useCallback(async () => {
|
||||
try {
|
||||
const res = await window.ipc.invoke('codeSession:gitStatus', { sessionId: session.id })
|
||||
setGitStatus(res)
|
||||
} catch {
|
||||
setGitStatus(null)
|
||||
}
|
||||
}, [session.id])
|
||||
|
||||
useEffect(() => {
|
||||
setTab('changes')
|
||||
setDiffPath(null)
|
||||
setFilePath(null)
|
||||
void refreshStatus()
|
||||
}, [refreshStatus])
|
||||
|
||||
// Refresh on turn end, and poll lightly while the agent is working — the
|
||||
// session cwd lives outside the workspace watcher, so there are no change
|
||||
// events to react to.
|
||||
useEffect(() => {
|
||||
if (status === 'idle') {
|
||||
void refreshStatus()
|
||||
return
|
||||
}
|
||||
const interval = setInterval(() => void refreshStatus(), 5000)
|
||||
return () => clearInterval(interval)
|
||||
}, [status, refreshStatus])
|
||||
|
||||
// Chat asked to show a specific file's diff.
|
||||
useEffect(() => {
|
||||
if (!openDiffPath) return
|
||||
// Tool events may carry absolute paths — make them cwd-relative.
|
||||
const rel = openDiffPath.startsWith(session.cwd + '/')
|
||||
? openDiffPath.slice(session.cwd.length + 1)
|
||||
: openDiffPath
|
||||
setTab('changes')
|
||||
setDiffPath(rel)
|
||||
onDiffOpened()
|
||||
}, [openDiffPath, session.cwd, onDiffOpened])
|
||||
|
||||
const handleMergeBack = async () => {
|
||||
setMerging(true)
|
||||
try {
|
||||
const res = await window.ipc.invoke('codeSession:mergeBack', { sessionId: session.id })
|
||||
if (res.ok) {
|
||||
toast.success(res.message)
|
||||
onSessionChanged()
|
||||
} else {
|
||||
toast.error(res.message, { duration: 10000 })
|
||||
}
|
||||
} finally {
|
||||
setMerging(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCleanup = async (deleteBranch: boolean) => {
|
||||
const res = await window.ipc.invoke('codeSession:cleanupWorktree', { sessionId: session.id, deleteBranch })
|
||||
if (res.success) {
|
||||
toast.success('Worktree removed. The session now works directly in the repo.')
|
||||
onSessionChanged()
|
||||
} else {
|
||||
toast.error(res.error ?? 'Failed to remove worktree')
|
||||
}
|
||||
}
|
||||
|
||||
const dirtyCount = gitStatus?.files.length ?? 0
|
||||
const worktreeActive = session.worktree && !session.worktree.removedAt
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
{/* Header: branch + worktree controls */}
|
||||
<div className="flex items-center gap-2 border-b px-3 py-2">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1.5 text-xs text-muted-foreground">
|
||||
{gitStatus?.isRepo ? (
|
||||
<>
|
||||
<GitBranch className="size-3.5 shrink-0" />
|
||||
<span className="truncate font-mono">{gitStatus.branch ?? '(no branch)'}</span>
|
||||
{dirtyCount > 0 && (
|
||||
<span className="shrink-0 rounded-full bg-amber-500/15 px-1.5 py-0.5 text-[10px] font-medium text-amber-600">
|
||||
{dirtyCount} changed
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span>Not a git repository</span>
|
||||
)}
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2" onClick={() => void refreshStatus()} title="Refresh">
|
||||
<RefreshCw className="size-3.5" />
|
||||
</Button>
|
||||
{worktreeActive && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="h-7 gap-1.5 px-2 text-xs">
|
||||
<GitMerge className="size-3.5" />
|
||||
Worktree
|
||||
<MoreHorizontal className="size-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem disabled={merging} onClick={() => void handleMergeBack()}>
|
||||
<GitMerge className="size-4" />
|
||||
Merge back into repo
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => void handleCleanup(false)}>
|
||||
<Trash2 className="size-4" />
|
||||
Remove worktree (keep branch)
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem variant="destructive" onClick={() => void handleCleanup(true)}>
|
||||
<Trash2 className="size-4" />
|
||||
Remove worktree and branch
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex items-center gap-1 border-b px-3 py-1.5">
|
||||
{(['changes', 'files'] as const).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
onClick={() => setTab(t)}
|
||||
className={cn(
|
||||
'rounded-full px-3 py-1 text-xs font-medium capitalize transition-colors',
|
||||
tab === t ? 'bg-foreground text-background' : 'text-muted-foreground hover:bg-muted',
|
||||
)}
|
||||
>
|
||||
{t === 'changes' ? `Changes${dirtyCount > 0 ? ` (${dirtyCount})` : ''}` : 'Files'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="min-h-0 flex-1">
|
||||
{tab === 'changes' && (
|
||||
diffPath ? (
|
||||
<DiffViewer sessionId={session.id} path={diffPath} onClose={() => setDiffPath(null)} />
|
||||
) : (
|
||||
<div className="h-full overflow-auto p-2">
|
||||
{!gitStatus?.isRepo && (
|
||||
<p className="p-3 text-sm text-muted-foreground">
|
||||
This folder isn't a git repository, so there's nothing to diff. The Files tab still works.
|
||||
</p>
|
||||
)}
|
||||
{gitStatus?.isRepo && gitStatus.files.length === 0 && (
|
||||
<p className="p-3 text-sm text-muted-foreground">No uncommitted changes.</p>
|
||||
)}
|
||||
{gitStatus?.files.map((file) => {
|
||||
const Icon = STATE_ICON[file.state]
|
||||
return (
|
||||
<button
|
||||
key={file.path}
|
||||
type="button"
|
||||
onClick={() => setDiffPath(file.path)}
|
||||
className="flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-xs hover:bg-muted"
|
||||
title={file.path}
|
||||
>
|
||||
<Icon className={cn(
|
||||
'size-3.5 shrink-0',
|
||||
file.state === 'deleted' ? 'text-red-500' : file.state === 'modified' || file.state === 'renamed' ? 'text-amber-500' : 'text-green-600',
|
||||
)} />
|
||||
<span className="min-w-0 flex-1 truncate font-mono">{file.path}</span>
|
||||
{file.insertions !== null && <span className="shrink-0 text-green-600">+{file.insertions}</span>}
|
||||
{file.deletions !== null && <span className="shrink-0 text-red-500">−{file.deletions}</span>}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
{tab === 'files' && (
|
||||
filePath ? (
|
||||
<CodeFileViewer sessionId={session.id} path={filePath} onClose={() => setFilePath(null)} />
|
||||
) : (
|
||||
<div className="h-full overflow-auto">
|
||||
<CodeFileTree sessionId={session.id} selectedPath={filePath} onSelectFile={setFilePath} />
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
{tab === 'changes' && !diffPath && dirtyCount > 0 && (
|
||||
<div className="border-t px-3 py-1.5 text-[11px] text-muted-foreground">
|
||||
<FileDiff className="mr-1 inline size-3" />
|
||||
Click a file to review its diff.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -28,7 +28,7 @@ type PlanRow = { kind: 'plan'; id: string; entries: { content: string; status?:
|
|||
type PermRow = { kind: 'perm'; id: string; title: string; decision: string }
|
||||
type Row = TextRow | ToolRow | PlanRow | PermRow
|
||||
|
||||
function reduceEvents(events: CodeRunEvent[]): Row[] {
|
||||
export function reduceEvents(events: CodeRunEvent[]): Row[] {
|
||||
const rows: Row[] = []
|
||||
const toolIdx = new Map<string, number>()
|
||||
let planIdx = -1
|
||||
|
|
@ -107,7 +107,14 @@ function planMarker(status?: string) {
|
|||
|
||||
const basename = (p: string) => p.split(/[\\/]/).pop() || p
|
||||
|
||||
function CodingRunTimeline({ events }: { events: CodeRunEvent[] }) {
|
||||
export function CodingRunTimeline({
|
||||
events,
|
||||
onOpenDiff,
|
||||
}: {
|
||||
events: CodeRunEvent[]
|
||||
// When set, changed-file names become clickable (the Code section opens the diff).
|
||||
onOpenDiff?: (path: string) => void
|
||||
}) {
|
||||
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>
|
||||
|
|
@ -117,7 +124,7 @@ function CodingRunTimeline({ events }: { events: CodeRunEvent[] }) {
|
|||
{rows.map((row) => {
|
||||
if (row.kind === 'text') {
|
||||
return (
|
||||
<p key={row.id} className="whitespace-pre-wrap text-sm leading-relaxed text-foreground/90">
|
||||
<p key={row.id} className="whitespace-pre-wrap break-words text-sm leading-relaxed text-foreground/90">
|
||||
{row.text}
|
||||
</p>
|
||||
)
|
||||
|
|
@ -136,9 +143,21 @@ function CodingRunTimeline({ events }: { events: CodeRunEvent[] }) {
|
|||
{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>
|
||||
onOpenDiff ? (
|
||||
<button
|
||||
key={d}
|
||||
type="button"
|
||||
onClick={() => onOpenDiff(d)}
|
||||
className="truncate text-left font-mono text-xs text-muted-foreground hover:text-foreground hover:underline"
|
||||
title={d}
|
||||
>
|
||||
{basename(d)}
|
||||
</button>
|
||||
) : (
|
||||
<span key={d} className="truncate font-mono text-xs text-muted-foreground" title={d}>
|
||||
{basename(d)}
|
||||
</span>
|
||||
)
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -612,6 +612,43 @@ function ComposeToolbar({ editor, onOpenLink }: { editor: Editor; onOpenLink: ()
|
|||
)
|
||||
}
|
||||
|
||||
type ContactSuggestion = {
|
||||
name: string
|
||||
email: string
|
||||
}
|
||||
|
||||
function formatContactToken(c: ContactSuggestion): string {
|
||||
return c.name ? `${c.name} <${c.email}>` : c.email
|
||||
}
|
||||
|
||||
// Stable hue per email so the avatar circle keeps a consistent color.
|
||||
function contactHue(email: string): number {
|
||||
let h = 0
|
||||
for (let i = 0; i < email.length; i++) h = (h * 31 + email.charCodeAt(i)) >>> 0
|
||||
return h % 360
|
||||
}
|
||||
|
||||
function contactInitial(c: ContactSuggestion): string {
|
||||
const src = (c.name || c.email).trim()
|
||||
return (src[0] || '?').toUpperCase()
|
||||
}
|
||||
|
||||
// Renders a string with the matched substring wrapped in <mark>.
|
||||
function HighlightedText({ text, query }: { text: string; query: string }) {
|
||||
if (!query) return <>{text}</>
|
||||
const lower = text.toLowerCase()
|
||||
const q = query.toLowerCase()
|
||||
const idx = lower.indexOf(q)
|
||||
if (idx < 0) return <>{text}</>
|
||||
return (
|
||||
<>
|
||||
{text.slice(0, idx)}
|
||||
<mark className="gmail-recipient-suggestion-match">{text.slice(idx, idx + q.length)}</mark>
|
||||
{text.slice(idx + q.length)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function RecipientField({
|
||||
label,
|
||||
value,
|
||||
|
|
@ -626,34 +663,123 @@ function RecipientField({
|
|||
trailing?: React.ReactNode
|
||||
}) {
|
||||
const [draft, setDraft] = useState('')
|
||||
const [suggestions, setSuggestions] = useState<ContactSuggestion[]>([])
|
||||
const [activeIndex, setActiveIndex] = useState(0)
|
||||
const [isFocused, setIsFocused] = useState(false)
|
||||
const [queryShown, setQueryShown] = useState('')
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const fieldRef = useRef<HTMLDivElement>(null)
|
||||
const listRef = useRef<HTMLUListElement>(null)
|
||||
const queryTokenRef = useRef(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (autoFocus) inputRef.current?.focus()
|
||||
}, [autoFocus])
|
||||
|
||||
const excludeEmails = useMemo(
|
||||
() => value.map((token) => extractAddress(token).toLowerCase()).filter(Boolean),
|
||||
[value],
|
||||
)
|
||||
|
||||
// Debounced contact search — only runs when the user has actually typed
|
||||
// something. An empty draft (including the post-pick reset) closes the menu.
|
||||
useEffect(() => {
|
||||
const trimmed = draft.trim()
|
||||
if (!isFocused || !trimmed) {
|
||||
queryTokenRef.current++
|
||||
setSuggestions([])
|
||||
return
|
||||
}
|
||||
const token = ++queryTokenRef.current
|
||||
const timer = window.setTimeout(async () => {
|
||||
try {
|
||||
const result = (await window.ipc.invoke('gmail:searchContacts', {
|
||||
query: draft,
|
||||
limit: 8,
|
||||
excludeEmails,
|
||||
})) as { contacts?: ContactSuggestion[] } | undefined
|
||||
if (token !== queryTokenRef.current) return
|
||||
setSuggestions(result?.contacts ?? [])
|
||||
setQueryShown(trimmed)
|
||||
setActiveIndex(0)
|
||||
} catch {
|
||||
if (token !== queryTokenRef.current) return
|
||||
setSuggestions([])
|
||||
}
|
||||
}, 60)
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [draft, isFocused, excludeEmails])
|
||||
|
||||
// Keep the active row scrolled into view during keyboard navigation.
|
||||
useEffect(() => {
|
||||
const list = listRef.current
|
||||
if (!list) return
|
||||
const node = list.children[activeIndex] as HTMLElement | undefined
|
||||
node?.scrollIntoView({ block: 'nearest' })
|
||||
}, [activeIndex, suggestions])
|
||||
|
||||
const commit = (raw: string) => {
|
||||
const additions = splitAddresses(raw)
|
||||
if (additions.length === 0) return
|
||||
onChange(dedupeRecipients([...value, ...additions], new Set()))
|
||||
setDraft('')
|
||||
setSuggestions([])
|
||||
}
|
||||
|
||||
const pickSuggestion = (c: ContactSuggestion) => {
|
||||
commit(formatContactToken(c))
|
||||
// Keep focus in the input so the user can keep typing more recipients.
|
||||
inputRef.current?.focus()
|
||||
}
|
||||
|
||||
const onKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Enter' || event.key === ',' || event.key === ';' || (event.key === 'Tab' && draft.trim())) {
|
||||
const hasSuggestions = suggestions.length > 0
|
||||
if (event.key === 'ArrowDown' && hasSuggestions) {
|
||||
event.preventDefault()
|
||||
setActiveIndex((i) => (i + 1) % suggestions.length)
|
||||
return
|
||||
}
|
||||
if (event.key === 'ArrowUp' && hasSuggestions) {
|
||||
event.preventDefault()
|
||||
setActiveIndex((i) => (i - 1 + suggestions.length) % suggestions.length)
|
||||
return
|
||||
}
|
||||
if (event.key === 'Escape' && hasSuggestions) {
|
||||
event.preventDefault()
|
||||
setSuggestions([])
|
||||
return
|
||||
}
|
||||
if (event.key === 'Enter' || (event.key === 'Tab' && hasSuggestions)) {
|
||||
// Prefer the highlighted suggestion when one is present.
|
||||
if (hasSuggestions) {
|
||||
event.preventDefault()
|
||||
pickSuggestion(suggestions[activeIndex])
|
||||
return
|
||||
}
|
||||
if (event.key === 'Enter' && draft.trim()) {
|
||||
event.preventDefault()
|
||||
commit(draft)
|
||||
return
|
||||
}
|
||||
}
|
||||
if (event.key === ',' || event.key === ';') {
|
||||
if (draft.trim()) {
|
||||
event.preventDefault()
|
||||
commit(draft)
|
||||
}
|
||||
} else if (event.key === 'Backspace' && !draft && value.length > 0) {
|
||||
return
|
||||
}
|
||||
if (event.key === 'Backspace' && !draft && value.length > 0) {
|
||||
onChange(value.slice(0, -1))
|
||||
}
|
||||
}
|
||||
|
||||
const showSuggestions = isFocused && suggestions.length > 0
|
||||
|
||||
return (
|
||||
<div className="gmail-recipient-row">
|
||||
<span className="gmail-recipient-label">{label}</span>
|
||||
<div className="gmail-recipient-field">
|
||||
<div className="gmail-recipient-field" ref={fieldRef}>
|
||||
{value.map((token, index) => (
|
||||
<span key={`${token}-${index}`} className="gmail-recipient-chip" title={extractAddress(token)}>
|
||||
<span className="gmail-recipient-chip-label">{recipientLabel(token)}</span>
|
||||
|
|
@ -674,7 +800,16 @@ function RecipientField({
|
|||
value={draft}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
onBlur={() => { if (draft.trim()) commit(draft) }}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => {
|
||||
// Defer so a mousedown on a suggestion can pick it before the menu closes.
|
||||
window.setTimeout(() => {
|
||||
setIsFocused(false)
|
||||
if (inputRef.current && draft.trim() && document.activeElement !== inputRef.current) {
|
||||
commit(draft)
|
||||
}
|
||||
}, 80)
|
||||
}}
|
||||
onPaste={(event) => {
|
||||
const text = event.clipboardData.getData('text')
|
||||
if (text && /[,;\n]/.test(text)) {
|
||||
|
|
@ -683,6 +818,45 @@ function RecipientField({
|
|||
}
|
||||
}}
|
||||
/>
|
||||
{showSuggestions && (
|
||||
<ul className="gmail-recipient-suggestions" role="listbox" ref={listRef}>
|
||||
{suggestions.map((c, idx) => {
|
||||
const hue = contactHue(c.email)
|
||||
return (
|
||||
<li
|
||||
key={c.email}
|
||||
role="option"
|
||||
aria-selected={idx === activeIndex}
|
||||
className={cn('gmail-recipient-suggestion', idx === activeIndex && 'is-active')}
|
||||
onMouseDown={(event) => {
|
||||
// Prevent input blur before click fires.
|
||||
event.preventDefault()
|
||||
pickSuggestion(c)
|
||||
}}
|
||||
onMouseEnter={() => setActiveIndex(idx)}
|
||||
>
|
||||
<span
|
||||
className="gmail-recipient-suggestion-avatar"
|
||||
style={{ background: `hsl(${hue}, 60%, 42%)` }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{contactInitial(c)}
|
||||
</span>
|
||||
<span className="gmail-recipient-suggestion-text">
|
||||
<span className="gmail-recipient-suggestion-name">
|
||||
<HighlightedText text={c.name || c.email} query={queryShown} />
|
||||
</span>
|
||||
{c.name && (
|
||||
<span className="gmail-recipient-suggestion-email">
|
||||
<HighlightedText text={c.email} query={queryShown} />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
{trailing && <div className="gmail-recipient-trailing">{trailing}</div>}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -110,7 +110,12 @@ export function HtmlFileViewer({ path }: HtmlFileViewerProps) {
|
|||
<iframe
|
||||
key={path}
|
||||
src={iframeSrc}
|
||||
sandbox="allow-scripts"
|
||||
// `allow-popups` lets `target="_blank"` links reach the main process
|
||||
// window-open handler, which routes them to the system browser. Plain
|
||||
// links (same-frame navigations) are handled there via
|
||||
// `will-frame-navigate`. No `allow-same-origin` — the doc stays
|
||||
// origin-isolated.
|
||||
sandbox="allow-scripts allow-popups"
|
||||
className="h-full w-full border-0 bg-white"
|
||||
title="HTML preview"
|
||||
onLoad={() => setIframeLoaded(true)}
|
||||
|
|
|
|||
|
|
@ -1058,7 +1058,7 @@ export function MeetingsView({ onOpenNote, onTakeMeetingNotes, meetingState, mee
|
|||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenNote(note.path)}
|
||||
className="min-w-0 text-left text-sm font-medium text-foreground hover:underline"
|
||||
className="block w-full min-w-0 text-left text-sm font-medium text-foreground hover:underline"
|
||||
>
|
||||
<span className="block truncate">{note.name}</span>
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import * as React from "react"
|
||||
import { useState, useEffect, useCallback, useMemo } from "react"
|
||||
import { Server, Key, Shield, Palette, Monitor, Sun, Moon, Loader2, CheckCircle2, Plus, X, Wrench, Search, ChevronRight, Link2, Tags, Mail, BookOpen, User, Plug, HelpCircle, MessageCircle, Bug, Terminal, AlertTriangle, RefreshCw, PanelRight } from "lucide-react"
|
||||
import { Server, Key, Shield, Palette, Monitor, Sun, Moon, Loader2, CheckCircle2, Plus, X, Wrench, Search, ChevronRight, Link2, Tags, Mail, BookOpen, User, Plug, HelpCircle, MessageCircle, Bug, Terminal, AlertTriangle, RefreshCw, PanelRight, Bell } from "lucide-react"
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
|
|
@ -27,7 +27,7 @@ import { AccountSettings } from "@/components/settings/account-settings"
|
|||
import { ConnectedAccountsSettings } from "@/components/settings/connected-accounts-settings"
|
||||
import type { ApprovalPolicy } from "@x/shared/src/code-mode.js"
|
||||
|
||||
type ConfigTab = "account" | "connections" | "models" | "mcp" | "security" | "code-mode" | "appearance" | "note-tagging" | "help"
|
||||
type ConfigTab = "account" | "connections" | "models" | "mcp" | "security" | "code-mode" | "appearance" | "notifications" | "note-tagging" | "help"
|
||||
|
||||
interface TabConfig {
|
||||
id: ConfigTab
|
||||
|
|
@ -83,6 +83,12 @@ const tabs: TabConfig[] = [
|
|||
icon: Palette,
|
||||
description: "Customize the look and feel",
|
||||
},
|
||||
{
|
||||
id: "notifications",
|
||||
label: "Notifications",
|
||||
icon: Bell,
|
||||
description: "Choose which notifications you receive",
|
||||
},
|
||||
{
|
||||
id: "note-tagging",
|
||||
label: "Note Tagging",
|
||||
|
|
@ -1987,6 +1993,99 @@ function CodeModeSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
|||
)
|
||||
}
|
||||
|
||||
// --- Notification Settings ---
|
||||
|
||||
type NotificationCategoryKey = "chat_completion" | "new_email" | "agent_permission"
|
||||
|
||||
const NOTIFICATION_CATEGORIES: { key: NotificationCategoryKey; label: string; description: string }[] = [
|
||||
{
|
||||
key: "chat_completion",
|
||||
label: "Chat responses",
|
||||
description: "When an agent finishes responding while the app is in the background.",
|
||||
},
|
||||
{
|
||||
key: "new_email",
|
||||
label: "New email",
|
||||
description: "When a new email arrives during sync while the app is in the background.",
|
||||
},
|
||||
{
|
||||
key: "agent_permission",
|
||||
label: "Permission requests",
|
||||
description: "When an agent needs your approval to run a tool. Always shown, even when the app is focused.",
|
||||
},
|
||||
]
|
||||
|
||||
function NotificationSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
||||
const [categories, setCategories] = useState<Record<NotificationCategoryKey, boolean> | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!dialogOpen) return
|
||||
let cancelled = false
|
||||
async function load() {
|
||||
try {
|
||||
const result = await window.ipc.invoke("notifications:getSettings", null)
|
||||
if (!cancelled) setCategories(result.categories)
|
||||
} catch {
|
||||
if (!cancelled) toast.error("Failed to load notification settings")
|
||||
}
|
||||
}
|
||||
load()
|
||||
return () => { cancelled = true }
|
||||
}, [dialogOpen])
|
||||
|
||||
const handleToggle = useCallback(async (key: NotificationCategoryKey, next: boolean) => {
|
||||
// Optimistic update with rollback on failure.
|
||||
const previous = categories
|
||||
if (!previous) return
|
||||
const updated = { ...previous, [key]: next }
|
||||
setCategories(updated)
|
||||
setSaving(true)
|
||||
try {
|
||||
await window.ipc.invoke("notifications:setSettings", { categories: updated })
|
||||
} catch {
|
||||
setCategories(previous)
|
||||
toast.error("Failed to update notification settings")
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}, [categories])
|
||||
|
||||
if (!categories) {
|
||||
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="text-sm text-muted-foreground leading-relaxed">
|
||||
Choose which desktop notifications Rowboat sends you. Ambient notifications are only shown
|
||||
when the app is in the background.
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{NOTIFICATION_CATEGORIES.map((cat) => (
|
||||
<div key={cat.key} 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">{cat.label}</div>
|
||||
<div className="text-xs text-muted-foreground mt-0.5">{cat.description}</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={categories[cat.key]}
|
||||
onCheckedChange={(next) => handleToggle(cat.key, next)}
|
||||
disabled={saving}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Main Settings Dialog ---
|
||||
|
||||
export function SettingsDialog({ children, defaultTab = "account", open: controlledOpen, onOpenChange }: SettingsDialogProps) {
|
||||
|
|
@ -2034,7 +2133,7 @@ export function SettingsDialog({ children, defaultTab = "account", open: control
|
|||
}
|
||||
|
||||
const loadConfig = useCallback(async (tab: ConfigTab) => {
|
||||
if (tab === "appearance" || tab === "models" || tab === "note-tagging" || tab === "account" || tab === "connections" || tab === "help" || tab === "code-mode") return
|
||||
if (tab === "appearance" || tab === "models" || tab === "note-tagging" || tab === "account" || tab === "connections" || tab === "help" || tab === "code-mode" || tab === "notifications") return
|
||||
const tabConfig = tabs.find((t) => t.id === tab)!
|
||||
if (!tabConfig.path) return
|
||||
setLoading(true)
|
||||
|
|
@ -2142,7 +2241,7 @@ export function SettingsDialog({ children, defaultTab = "account", open: control
|
|||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className={cn("flex-1 p-4 min-h-0", (activeTab === "models" || activeTab === "connections" || activeTab === "account" || activeTab === "code-mode") ? "overflow-y-auto" : activeTab === "note-tagging" ? "overflow-hidden flex flex-col" : "overflow-hidden")}>
|
||||
<div className={cn("flex-1 p-4 min-h-0", (activeTab === "models" || activeTab === "connections" || activeTab === "account" || activeTab === "code-mode" || activeTab === "notifications") ? "overflow-y-auto" : activeTab === "note-tagging" ? "overflow-hidden flex flex-col" : "overflow-hidden")}>
|
||||
{activeTab === "account" ? (
|
||||
<AccountSettings dialogOpen={open} />
|
||||
) : activeTab === "connections" ? (
|
||||
|
|
@ -2165,6 +2264,8 @@ export function SettingsDialog({ children, defaultTab = "account", open: control
|
|||
<NoteTaggingSettings dialogOpen={open} />
|
||||
) : activeTab === "appearance" ? (
|
||||
<AppearanceSettings />
|
||||
) : activeTab === "notifications" ? (
|
||||
<NotificationSettings dialogOpen={open} />
|
||||
) : activeTab === "help" ? (
|
||||
<HelpSettings />
|
||||
) : activeTab === "code-mode" ? (
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { useCallback, useEffect, useRef, useState } from "react"
|
|||
import {
|
||||
Bot,
|
||||
ChevronRight,
|
||||
Code2,
|
||||
FileText,
|
||||
FilePlus,
|
||||
Folder,
|
||||
|
|
@ -168,6 +169,7 @@ type SidebarContentPanelProps = {
|
|||
knowledgeActions: KnowledgeActions
|
||||
bgTaskSummaries?: TaskSummary[]
|
||||
onOpenMeetings?: () => void
|
||||
onOpenCode?: () => void
|
||||
onOpenBgTasks?: () => void
|
||||
onOpenAgent?: (slug: string) => void
|
||||
recentRuns?: { id: string; title?: string; createdAt: string }[]
|
||||
|
|
@ -178,7 +180,7 @@ type SidebarContentPanelProps = {
|
|||
onToggleBrowser?: () => void
|
||||
onVoiceNoteCreated?: (path: string) => void
|
||||
/** Which primary destination is currently active, for nav highlighting. */
|
||||
activeNav?: 'home' | 'email' | 'meetings' | 'knowledge' | 'agents' | 'workspaces' | null
|
||||
activeNav?: 'home' | 'email' | 'meetings' | 'code' | 'knowledge' | 'agents' | 'workspaces' | null
|
||||
/** Live meeting recording state, so the recording row can show its indicator/stop. */
|
||||
meetingRecordingState?: 'idle' | 'connecting' | 'recording' | 'stopping'
|
||||
recordingMeetingSource?: string | null
|
||||
|
|
@ -416,6 +418,7 @@ export function SidebarContentPanel({
|
|||
knowledgeActions,
|
||||
bgTaskSummaries = [],
|
||||
onOpenMeetings,
|
||||
onOpenCode,
|
||||
onOpenBgTasks,
|
||||
onOpenAgent,
|
||||
recentRuns = [],
|
||||
|
|
@ -446,6 +449,21 @@ export function SidebarContentPanel({
|
|||
const [emailThreads, setEmailThreads] = useState<SidebarEmailThread[]>([])
|
||||
const [meetings, setMeetings] = useState<UpcomingMeeting[]>([])
|
||||
const [quickAccessExpanded, setQuickAccessExpanded] = useState(true)
|
||||
// The Code section only makes sense with a coding agent available — same
|
||||
// flag the chat composer's code chip uses (auto-on when Claude Code or
|
||||
// Codex is installed + signed in; explicit toggle in settings wins).
|
||||
const [codeModeEnabled, setCodeModeEnabled] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const load = () => {
|
||||
window.ipc.invoke('codeMode:getConfig', null)
|
||||
.then((r) => setCodeModeEnabled(r.enabled))
|
||||
.catch(() => setCodeModeEnabled(false))
|
||||
}
|
||||
load()
|
||||
window.addEventListener('code-mode-config-changed', load)
|
||||
return () => window.removeEventListener('code-mode-config-changed', load)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
|
@ -834,6 +852,14 @@ export function SidebarContentPanel({
|
|||
</div>
|
||||
) : null}
|
||||
</SidebarMenuItem>
|
||||
{codeModeEnabled && (
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton isActive={activeNav === 'code'} onClick={onOpenCode}>
|
||||
<Code2 className="size-4 shrink-0" />
|
||||
<span className="flex-1 truncate">Code</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
)}
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
isActive={activeNav === 'knowledge'}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useCallback, useRef, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { buildDeepgramListenUrl } from '@/lib/deepgram-listen-url';
|
||||
import { useRowboatAccount } from '@/hooks/useRowboatAccount';
|
||||
|
||||
|
|
@ -21,8 +22,37 @@ const DEEPGRAM_LISTEN_URL = `wss://api.deepgram.com/v1/listen?${DEEPGRAM_PARAMS.
|
|||
// RMS threshold: system audio above this = "active" (speakers playing)
|
||||
const SYSTEM_AUDIO_GATE_THRESHOLD = 0.005;
|
||||
|
||||
// Auto-stop after 2 minutes of silence (no transcript from Deepgram)
|
||||
const SILENCE_AUTO_STOP_MS = 2 * 60 * 1000;
|
||||
// RMS threshold for "someone is talking" on either channel. Drives silence
|
||||
// detection — kept a touch above the gate threshold so faint room noise on the
|
||||
// mic doesn't read as speech and keep a finished recording alive.
|
||||
const SPEECH_RMS_THRESHOLD = 0.01;
|
||||
|
||||
// Silence handling. "Silence" = no audio above SPEECH_RMS_THRESHOLD on EITHER
|
||||
// the mic or the system-audio channel (i.e. nobody — local or remote — talking).
|
||||
// - After SILENCE_NUDGE_MS we ask the user (toast) whether to stop.
|
||||
// - After SILENCE_BACKSTOP_MS we stop unconditionally.
|
||||
// - Once past the linked calendar event's end time we use the shorter
|
||||
// POST_CALENDAR_END_SILENCE_MS, since a lull after the scheduled end is a
|
||||
// strong signal the meeting is actually over.
|
||||
const SILENCE_NUDGE_MS = 2 * 60 * 1000;
|
||||
const SILENCE_BACKSTOP_MS = 5 * 60 * 1000;
|
||||
const POST_CALENDAR_END_SILENCE_MS = 2 * 60 * 1000;
|
||||
// How often the silence checker runs.
|
||||
const SILENCE_CHECK_INTERVAL_MS = 5 * 1000;
|
||||
|
||||
// On macOS (ScreenCaptureKit) the system-audio track never fires "ended"/"mute"
|
||||
// when the meeting ends, and its readyState stays "live" — only track.muted flips
|
||||
// to true. But muted is ambiguous: it also goes true whenever no system audio is
|
||||
// playing (a quiet but live meeting), so muted alone can't safely trigger a stop.
|
||||
// See the poll in start() for how the muted signal is gated on the scheduled
|
||||
// calendar end so a quiet stretch never cuts a live meeting short.
|
||||
const TRACK_POLL_INTERVAL_MS = 3 * 1000;
|
||||
const MUTE_POLLS_TO_STOP = 3;
|
||||
|
||||
// The ScreenCaptureKit quirk above is macOS-only; on Windows the track's "ended"
|
||||
// event fires normally (handled by the listener in start()), so the poll below is
|
||||
// gated to macOS.
|
||||
const isMac = typeof navigator !== 'undefined' && navigator.platform.toLowerCase().includes('mac');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Headphone detection
|
||||
|
|
@ -119,7 +149,17 @@ export function useMeetingTranscription(onAutoStop?: () => void) {
|
|||
const interimRef = useRef<Map<number, { speaker: string; text: string }>>(new Map());
|
||||
const notePathRef = useRef<string>('');
|
||||
const writeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const silenceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
// Silence detection: timestamp of the last speech-level audio on either
|
||||
// channel, plus the interval that checks it. calendarEndMsRef holds the
|
||||
// linked event's end time (null if none).
|
||||
const lastAudioActivityRef = useRef<number>(0);
|
||||
const silenceCheckRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const calendarEndMsRef = useRef<number | null>(null);
|
||||
const nudgeToastIdRef = useRef<string | number | null>(null);
|
||||
// On macOS (ScreenCaptureKit) the system-audio track doesn't reliably fire
|
||||
// "ended"/"mute" when the meeting ends, so we poll its readyState/muted
|
||||
// state instead.
|
||||
const trackPollingRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const onAutoStopRef = useRef(onAutoStop);
|
||||
onAutoStopRef.current = onAutoStop;
|
||||
const dateRef = useRef<string>('');
|
||||
|
|
@ -161,9 +201,17 @@ export function useMeetingTranscription(onAutoStop?: () => void) {
|
|||
clearTimeout(writeTimerRef.current);
|
||||
writeTimerRef.current = null;
|
||||
}
|
||||
if (silenceTimerRef.current) {
|
||||
clearTimeout(silenceTimerRef.current);
|
||||
silenceTimerRef.current = null;
|
||||
if (silenceCheckRef.current) {
|
||||
clearInterval(silenceCheckRef.current);
|
||||
silenceCheckRef.current = null;
|
||||
}
|
||||
if (nudgeToastIdRef.current !== null) {
|
||||
toast.dismiss(nudgeToastIdRef.current);
|
||||
nudgeToastIdRef.current = null;
|
||||
}
|
||||
if (trackPollingRef.current) {
|
||||
clearInterval(trackPollingRef.current);
|
||||
trackPollingRef.current = null;
|
||||
}
|
||||
if (processorRef.current) {
|
||||
processorRef.current.disconnect();
|
||||
|
|
@ -279,13 +327,6 @@ export function useMeetingTranscription(onAutoStop?: () => void) {
|
|||
const transcript = data.channel.alternatives[0].transcript;
|
||||
if (!transcript) return;
|
||||
|
||||
// Reset silence auto-stop timer on any transcript
|
||||
if (silenceTimerRef.current) clearTimeout(silenceTimerRef.current);
|
||||
silenceTimerRef.current = setTimeout(() => {
|
||||
console.log('[meeting] 2 minutes of silence — auto-stopping');
|
||||
onAutoStopRef.current?.();
|
||||
}, SILENCE_AUTO_STOP_MS);
|
||||
|
||||
const channelIndex = data.channel_index?.[0] ?? 0;
|
||||
const isMic = channelIndex === 0;
|
||||
|
||||
|
|
@ -325,6 +366,56 @@ export function useMeetingTranscription(onAutoStop?: () => void) {
|
|||
const systemStream = systemResult.value;
|
||||
systemStreamRef.current = systemStream;
|
||||
|
||||
// If the shared source goes away (user closes the call window / clicks
|
||||
// "Stop sharing"), the track fires "ended" — treat that as the meeting
|
||||
// ending and stop. Our own cleanup() calls track.stop(), which does NOT
|
||||
// fire "ended", so this won't double-trigger on a manual stop.
|
||||
systemStream.getAudioTracks().forEach(track => {
|
||||
track.addEventListener('ended', () => {
|
||||
console.log('[meeting] system-audio track ended (shared source closed) — auto-stopping');
|
||||
onAutoStopRef.current?.();
|
||||
});
|
||||
});
|
||||
|
||||
// On macOS the system-audio track's "ended"/"mute" events don't fire when
|
||||
// the meeting ends, so poll its state instead. (On Windows the "ended"
|
||||
// listener above already covers this, so the poll is macOS-only.)
|
||||
//
|
||||
// - readyState === 'ended' is unambiguous (the source is gone) → stop now.
|
||||
// It never actually fires on macOS (readyState stays 'live'); it's just
|
||||
// a safety net should polling ever observe the track ending.
|
||||
// - muted is ambiguous on macOS: it flips true both when the meeting ends
|
||||
// AND when nothing is playing system audio (a quiet but live meeting).
|
||||
// So we only treat sustained mute as "meeting over" once we're past the
|
||||
// linked event's scheduled end — a dead audio track after the meeting
|
||||
// was due to finish is a strong signal. With no calendar event, or
|
||||
// before the scheduled end, we DON'T hard-stop on mute; the silence
|
||||
// checker's nudge + backstop handles it, so a quiet stretch can never
|
||||
// silently cut a live meeting short.
|
||||
const pollTrack = systemStream.getAudioTracks()[0];
|
||||
if (isMac && pollTrack) {
|
||||
let mutedPolls = 0;
|
||||
if (trackPollingRef.current) clearInterval(trackPollingRef.current);
|
||||
trackPollingRef.current = setInterval(() => {
|
||||
if (pollTrack.readyState === 'ended') {
|
||||
console.log('[meeting] system-audio track ended (poll) — auto-stopping');
|
||||
onAutoStopRef.current?.();
|
||||
return;
|
||||
}
|
||||
if (pollTrack.muted) {
|
||||
mutedPolls++;
|
||||
const endMs = calendarEndMsRef.current;
|
||||
const pastCalendarEnd = endMs != null && Date.now() > endMs;
|
||||
if (pastCalendarEnd && mutedPolls >= MUTE_POLLS_TO_STOP) {
|
||||
console.log('[meeting] system-audio track muted past scheduled end (poll) — auto-stopping');
|
||||
onAutoStopRef.current?.();
|
||||
}
|
||||
} else {
|
||||
mutedPolls = 0;
|
||||
}
|
||||
}, TRACK_POLL_INTERVAL_MS);
|
||||
}
|
||||
|
||||
// ----- Audio pipeline -----
|
||||
const audioCtx = new AudioContext({ sampleRate: 16000 });
|
||||
audioCtxRef.current = audioCtx;
|
||||
|
|
@ -345,24 +436,33 @@ export function useMeetingTranscription(onAutoStop?: () => void) {
|
|||
const micRaw = e.inputBuffer.getChannelData(0);
|
||||
const sysRaw = e.inputBuffer.getChannelData(1);
|
||||
|
||||
// RMS of each channel, computed once per frame and reused for
|
||||
// silence detection and gating the mic in speaker mode.
|
||||
let micSum = 0;
|
||||
for (let i = 0; i < micRaw.length; i++) micSum += micRaw[i] * micRaw[i];
|
||||
const micRms = Math.sqrt(micSum / micRaw.length);
|
||||
let sysSum = 0;
|
||||
for (let i = 0; i < sysRaw.length; i++) sysSum += sysRaw[i] * sysRaw[i];
|
||||
const sysRms = Math.sqrt(sysSum / sysRaw.length);
|
||||
|
||||
// Reset the silence clock whenever EITHER channel has speech-level
|
||||
// audio. Uses the raw mic (pre-gating) so the user's own voice counts
|
||||
// even in speaker mode where the outgoing mic gets muted.
|
||||
if (micRms > SPEECH_RMS_THRESHOLD || sysRms > SPEECH_RMS_THRESHOLD) {
|
||||
lastAudioActivityRef.current = Date.now();
|
||||
}
|
||||
|
||||
// Mode 1 (headphones): pass both streams through unmodified
|
||||
// Mode 2 (speakers): gate/mute mic when system audio is active
|
||||
let micOut: Float32Array;
|
||||
if (usingHeadphones) {
|
||||
micOut = micRaw;
|
||||
} else if (sysRms > SYSTEM_AUDIO_GATE_THRESHOLD) {
|
||||
// System audio is playing — mute mic to prevent bleed
|
||||
micOut = new Float32Array(micRaw.length); // all zeros
|
||||
} else {
|
||||
// Compute system audio RMS to detect activity
|
||||
let sysSum = 0;
|
||||
for (let i = 0; i < sysRaw.length; i++) sysSum += sysRaw[i] * sysRaw[i];
|
||||
const sysRms = Math.sqrt(sysSum / sysRaw.length);
|
||||
|
||||
if (sysRms > SYSTEM_AUDIO_GATE_THRESHOLD) {
|
||||
// System audio is playing — mute mic to prevent bleed
|
||||
micOut = new Float32Array(micRaw.length); // all zeros
|
||||
} else {
|
||||
// System audio is silent — pass mic through
|
||||
micOut = micRaw;
|
||||
}
|
||||
// System audio is silent — pass mic through
|
||||
micOut = micRaw;
|
||||
}
|
||||
|
||||
// Interleave mic (ch0) + system audio (ch1) into stereo int16 PCM
|
||||
|
|
@ -391,6 +491,12 @@ export function useMeetingTranscription(onAutoStop?: () => void) {
|
|||
const notePath = `knowledge/Meetings/rowboat/${dateFolder}/${filename}.md`;
|
||||
notePathRef.current = notePath;
|
||||
calendarEventRef.current = calendarEvent;
|
||||
|
||||
// Parse the linked event's end time (timed events only) so the silence
|
||||
// window can shorten once the meeting is past its scheduled end.
|
||||
const calEndMs = calendarEvent?.end?.dateTime ? Date.parse(calendarEvent.end.dateTime) : NaN;
|
||||
calendarEndMsRef.current = Number.isFinite(calEndMs) ? calEndMs : null;
|
||||
|
||||
const initialContent = formatTranscript([], dateStr, calendarEvent);
|
||||
await window.ipc.invoke('workspace:writeFile', {
|
||||
path: notePath,
|
||||
|
|
@ -398,6 +504,45 @@ export function useMeetingTranscription(onAutoStop?: () => void) {
|
|||
opts: { encoding: 'utf8', mkdirp: true },
|
||||
});
|
||||
|
||||
// Arm silence detection. Initialise the activity clock to "now" so the
|
||||
// checker is live from the very start of recording — a session that
|
||||
// never captures any audio still auto-stops at the backstop instead of
|
||||
// running forever.
|
||||
lastAudioActivityRef.current = Date.now();
|
||||
if (silenceCheckRef.current) clearInterval(silenceCheckRef.current);
|
||||
silenceCheckRef.current = setInterval(() => {
|
||||
const silentMs = Date.now() - lastAudioActivityRef.current;
|
||||
const endMs = calendarEndMsRef.current;
|
||||
const pastCalendarEnd = endMs != null && Date.now() > endMs;
|
||||
const hardStopMs = pastCalendarEnd ? POST_CALENDAR_END_SILENCE_MS : SILENCE_BACKSTOP_MS;
|
||||
|
||||
if (silentMs >= hardStopMs) {
|
||||
console.log(`[meeting] ${Math.round(silentMs / 1000)}s of silence${pastCalendarEnd ? ' (past scheduled end)' : ''} — auto-stopping`);
|
||||
onAutoStopRef.current?.();
|
||||
return;
|
||||
}
|
||||
|
||||
if (silentMs >= SILENCE_NUDGE_MS) {
|
||||
// Ask once; the toast persists until dismissed or acted on. Past
|
||||
// the scheduled end we skip straight to the hard stop above, so
|
||||
// the nudge only ever shows for an in-progress meeting.
|
||||
if (nudgeToastIdRef.current === null) {
|
||||
nudgeToastIdRef.current = toast('Still in a meeting?', {
|
||||
description: "It's been quiet for a couple of minutes.",
|
||||
duration: Infinity,
|
||||
action: {
|
||||
label: 'Stop recording',
|
||||
onClick: () => { onAutoStopRef.current?.(); },
|
||||
},
|
||||
});
|
||||
}
|
||||
} else if (nudgeToastIdRef.current !== null) {
|
||||
// Audio resumed before the backstop — retract the nudge.
|
||||
toast.dismiss(nudgeToastIdRef.current);
|
||||
nudgeToastIdRef.current = null;
|
||||
}
|
||||
}, SILENCE_CHECK_INTERVAL_MS);
|
||||
|
||||
setState('recording');
|
||||
return notePath;
|
||||
}, [state, cleanup, scheduleDebouncedWrite, refreshRowboatAccount]);
|
||||
|
|
|
|||
|
|
@ -151,6 +151,20 @@ export function useVoiceMode() {
|
|||
analytics.voiceInputStarted();
|
||||
posthog.people.set_once({ has_used_voice: true });
|
||||
|
||||
// Settle the OS-level microphone permission before capturing. On the
|
||||
// first-ever use (macOS) the permission is 'not-determined'; calling
|
||||
// getUserMedia directly would reject while the native prompt is up,
|
||||
// making the first mic click silently do nothing. Resolving it here
|
||||
// lets this same click proceed once the user grants access.
|
||||
const mic = await window.ipc
|
||||
.invoke('voice:ensureMicAccess', null)
|
||||
.catch(() => ({ granted: true }));
|
||||
if (!mic.granted) {
|
||||
console.error('Microphone access denied');
|
||||
stopAudioCapture();
|
||||
return;
|
||||
}
|
||||
|
||||
// Kick off mic + WebSocket in parallel, don't await WebSocket
|
||||
const [stream] = await Promise.all([
|
||||
navigator.mediaDevices.getUserMedia({ audio: true }).catch((err) => {
|
||||
|
|
@ -161,7 +175,10 @@ export function useVoiceMode() {
|
|||
]);
|
||||
|
||||
if (!stream) {
|
||||
setState('idle');
|
||||
// connectWs() may have already opened a socket — tear everything
|
||||
// down (close WS, reset buffers, state) rather than only resetting
|
||||
// state, which would leak the socket into the next attempt.
|
||||
stopAudioCapture();
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -192,7 +209,7 @@ export function useVoiceMode() {
|
|||
|
||||
source.connect(processor);
|
||||
processor.connect(audioCtx.destination);
|
||||
}, [state, connectWs]);
|
||||
}, [state, connectWs, stopAudioCapture]);
|
||||
|
||||
/** Stop recording and return the full transcript (finalized + any current interim) */
|
||||
const submit = useCallback((): string => {
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import { isBlocked, extractCommandNames } from "../application/lib/command-execu
|
|||
import { getFileAccessAllowList, type FileAccessGrant, type FileAccessOperation } from "../config/security.js";
|
||||
import { resolveFilePathForPermission } from "../filesystem/files.js";
|
||||
import container from "../di/container.js";
|
||||
import { notifyIfEnabled } from "../application/notification/notifier.js";
|
||||
import { IModelConfigRepo } from "../models/repo.js";
|
||||
import { createProvider } from "../models/models.js";
|
||||
import { resolveProviderConfig } from "../models/defaults.js";
|
||||
|
|
@ -377,6 +378,7 @@ export class AgentRuntime implements IAgentRuntime {
|
|||
type: "run-processing-start",
|
||||
subflow: [],
|
||||
});
|
||||
let totalEvents = 0;
|
||||
while (true) {
|
||||
// Check for abort before each iteration
|
||||
if (signal.aborted) {
|
||||
|
|
@ -417,6 +419,7 @@ export class AgentRuntime implements IAgentRuntime {
|
|||
throw error;
|
||||
}
|
||||
|
||||
totalEvents += eventCount;
|
||||
// if no events, break
|
||||
if (!eventCount) {
|
||||
break;
|
||||
|
|
@ -433,6 +436,27 @@ export class AgentRuntime implements IAgentRuntime {
|
|||
};
|
||||
await this.runsRepo.appendEvents(runId, [stoppedEvent]);
|
||||
await this.bus.publish(stoppedEvent);
|
||||
} else if (totalEvents > 0) {
|
||||
// The run reached a natural stopping point and actually did
|
||||
// something this cycle. Notify "chat completion" — unless it
|
||||
// paused on a permission request, which surfaces its own
|
||||
// notification (distinguish by inspecting the final state).
|
||||
const finalRun = await this.runsRepo.fetch(runId);
|
||||
if (finalRun) {
|
||||
const finalState = new AgentState();
|
||||
for (const event of finalRun.log) {
|
||||
finalState.ingest(event);
|
||||
}
|
||||
if (finalState.getPendingPermissions().length === 0) {
|
||||
void notifyIfEnabled("chat_completion", {
|
||||
title: "Response ready",
|
||||
message: "Your agent finished responding.",
|
||||
link: `rowboat://open?type=chat&runId=${runId}`,
|
||||
actionLabel: "Open",
|
||||
onlyWhenBackground: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Run ${runId} failed:`, error);
|
||||
|
|
@ -1181,6 +1205,8 @@ export async function* streamAgent({
|
|||
let voiceOutput: 'summary' | 'full' | null = null;
|
||||
let searchEnabled = false;
|
||||
let codeMode: 'claude' | 'codex' | null = null;
|
||||
let codeCwd: string | null = null;
|
||||
let codePolicy: 'ask' | 'auto-approve-reads' | 'yolo' | null = null;
|
||||
let middlePaneContext:
|
||||
| { kind: 'note'; path: string; content: string }
|
||||
| { kind: 'browser'; url: string; title: string }
|
||||
|
|
@ -1280,6 +1306,8 @@ export async function* streamAgent({
|
|||
abortRegistry,
|
||||
publish: (event) => bus.publish(event),
|
||||
codeMode,
|
||||
codeCwd,
|
||||
codePolicy,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
@ -1339,6 +1367,8 @@ export async function* streamAgent({
|
|||
// 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;
|
||||
codeCwd = msg.codeCwd ?? null;
|
||||
codePolicy = msg.codePolicy ?? null;
|
||||
if (msg.voiceOutput) {
|
||||
voiceOutput = msg.voiceOutput;
|
||||
}
|
||||
|
|
@ -1436,7 +1466,7 @@ The chip is the single source of truth for which agent runs:
|
|||
|
||||
**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).
|
||||
- \`cwd\`: ${codeCwd ? `\`${codeCwd}\` (always — this coding session is pinned to that directory; never use another path)` : `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.
|
||||
|
|
@ -1545,6 +1575,16 @@ If the user's message is clearly NOT a coding request (small talk, an unrelated
|
|||
}
|
||||
|
||||
if (permissionCandidates.length > 0) {
|
||||
// Permission prompts block the run, so they surface even when the
|
||||
// app is focused (no onlyWhenBackground gate).
|
||||
const notifyPermissionPrompt = (toolCall: typeof permissionCandidates[number]["toolCall"]) => {
|
||||
void notifyIfEnabled("agent_permission", {
|
||||
title: "Permission needed",
|
||||
message: `${agent.name} wants to run "${toolCall.toolName}". Review to continue.`,
|
||||
link: `rowboat://open?type=chat&runId=${runId}`,
|
||||
actionLabel: "Review",
|
||||
});
|
||||
};
|
||||
if (state.permissionMode === "auto") {
|
||||
let decisionsByToolCallId = new Map<string, { decision: "allow" | "deny"; reason: string }>();
|
||||
try {
|
||||
|
|
@ -1578,6 +1618,7 @@ If the user's message is clearly NOT a coding request (small talk, an unrelated
|
|||
permission: candidate.permission,
|
||||
subflow: [],
|
||||
});
|
||||
notifyPermissionPrompt(candidate.toolCall);
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -1609,6 +1650,7 @@ If the user's message is clearly NOT a coding request (small talk, an unrelated
|
|||
permission: candidate.permission,
|
||||
subflow: [],
|
||||
});
|
||||
notifyPermissionPrompt(candidate.toolCall);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
|
@ -1621,6 +1663,7 @@ If the user's message is clearly NOT a coding request (small talk, an unrelated
|
|||
permission: candidate.permission,
|
||||
subflow: [],
|
||||
});
|
||||
notifyPermissionPrompt(candidate.toolCall);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { AsyncLocalStorage } from 'node:async_hooks';
|
||||
|
||||
export type UseCase = 'copilot_chat' | 'live_note_agent' | 'background_task_agent' | 'meeting_note' | 'knowledge_sync';
|
||||
export type UseCase = 'copilot_chat' | 'live_note_agent' | 'background_task_agent' | 'meeting_note' | 'knowledge_sync' | 'code_session';
|
||||
|
||||
export interface UseCaseContext {
|
||||
useCase: UseCase;
|
||||
|
|
|
|||
|
|
@ -10,7 +10,9 @@ export const skill = String.raw`
|
|||
A *background task* is a persistent agent the user configures once and the framework keeps firing — on a schedule, inside time-of-day windows, and/or in response to matching incoming events (Gmail threads, calendar changes). Each task lives at \`bg-tasks/<slug>/\` and owns two artifacts:
|
||||
|
||||
- \`task.yaml\` — the spec (the user's **instructions**, triggers, runtime state). You and the user both treat this as the source of truth.
|
||||
- \`index.md\` — the agent-owned body. The runtime never writes here; the bg-task agent does, each run.
|
||||
- \`index.md\` — the agent-owned body (a note). The runtime never writes here; the bg-task agent does, each run.
|
||||
|
||||
For **visual** output — a dashboard, a styled report, a metrics table with conditional colors, a chart — the agent may instead write a self-contained \`index.html\`, which the task view renders full-screen in a sandboxed iframe with CSS and layout preserved. The agent picks the format per run from the instructions; you don't set it, but when the ask is inherently visual, say so in the instructions (e.g. "…rendered as a styled HTML dashboard") so the agent leans that way.
|
||||
|
||||
A task is one of two shapes — the agent decides per run from the verbs in \`instructions\`:
|
||||
|
||||
|
|
|
|||
|
|
@ -824,16 +824,24 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
|||
// 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;
|
||||
// Code-section sessions pin the working directory — never trust the model's
|
||||
// cwd argument over the session's.
|
||||
const effectiveCwd = ctx.codeCwd ?? cwd;
|
||||
const manager = container.resolve<CodeModeManager>('codeModeManager');
|
||||
const registry = container.resolve<CodePermissionRegistry>('codePermissionRegistry');
|
||||
|
||||
// Approval policy from settings; default to asking the user.
|
||||
// Approval policy: the session's (Code section) wins, else global settings,
|
||||
// else 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'
|
||||
if (ctx.codePolicy) {
|
||||
policy = ctx.codePolicy;
|
||||
} else {
|
||||
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
|
||||
|
|
@ -850,7 +858,7 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
|||
const result = await manager.runPrompt({
|
||||
runId: ctx.runId,
|
||||
agent: effectiveAgent,
|
||||
cwd,
|
||||
cwd: effectiveCwd,
|
||||
prompt,
|
||||
policy,
|
||||
signal: ctx.signal,
|
||||
|
|
|
|||
|
|
@ -18,6 +18,11 @@ export interface ToolContext {
|
|||
// 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;
|
||||
// Set for Code-section sessions in Rowboat mode: the session's working directory
|
||||
// and approval policy. code_agent_run honors these over the model's cwd argument
|
||||
// and the global approval policy.
|
||||
codeCwd?: string | null;
|
||||
codePolicy?: 'ask' | 'auto-approve-reads' | 'yolo' | null;
|
||||
}
|
||||
|
||||
async function execMcpTool(agentTool: z.infer<typeof ToolAttachment> & { type: "mcp" }, input: Record<string, unknown>): Promise<unknown> {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ export type MiddlePaneContext =
|
|||
| { kind: 'browser'; url: string; title: string };
|
||||
|
||||
export type CodeMode = 'claude' | 'codex';
|
||||
export type CodePolicy = 'ask' | 'auto-approve-reads' | 'yolo';
|
||||
|
||||
type EnqueuedMessage = {
|
||||
messageId: string;
|
||||
|
|
@ -17,11 +18,16 @@ type EnqueuedMessage = {
|
|||
voiceOutput?: VoiceOutputMode;
|
||||
searchEnabled?: boolean;
|
||||
codeMode?: CodeMode;
|
||||
// Code-section sessions pin the coding agent's working directory and
|
||||
// approval policy for the turn (code_agent_run honors these over its
|
||||
// model-provided arguments / the global policy).
|
||||
codeCwd?: string;
|
||||
codePolicy?: CodePolicy;
|
||||
middlePaneContext?: MiddlePaneContext;
|
||||
};
|
||||
|
||||
export interface IMessageQueue {
|
||||
enqueue(runId: string, message: UserMessageContentType, voiceInput?: boolean, voiceOutput?: VoiceOutputMode, searchEnabled?: boolean, middlePaneContext?: MiddlePaneContext, codeMode?: CodeMode): Promise<string>;
|
||||
enqueue(runId: string, message: UserMessageContentType, voiceInput?: boolean, voiceOutput?: VoiceOutputMode, searchEnabled?: boolean, middlePaneContext?: MiddlePaneContext, codeMode?: CodeMode, codeCwd?: string, codePolicy?: CodePolicy): Promise<string>;
|
||||
dequeue(runId: string): Promise<EnqueuedMessage | null>;
|
||||
}
|
||||
|
||||
|
|
@ -37,7 +43,7 @@ export class InMemoryMessageQueue implements IMessageQueue {
|
|||
this.idGenerator = idGenerator;
|
||||
}
|
||||
|
||||
async enqueue(runId: string, message: UserMessageContentType, voiceInput?: boolean, voiceOutput?: VoiceOutputMode, searchEnabled?: boolean, middlePaneContext?: MiddlePaneContext, codeMode?: CodeMode): Promise<string> {
|
||||
async enqueue(runId: string, message: UserMessageContentType, voiceInput?: boolean, voiceOutput?: VoiceOutputMode, searchEnabled?: boolean, middlePaneContext?: MiddlePaneContext, codeMode?: CodeMode, codeCwd?: string, codePolicy?: CodePolicy): Promise<string> {
|
||||
if (!this.store[runId]) {
|
||||
this.store[runId] = [];
|
||||
}
|
||||
|
|
@ -49,6 +55,8 @@ export class InMemoryMessageQueue implements IMessageQueue {
|
|||
voiceOutput,
|
||||
searchEnabled,
|
||||
codeMode,
|
||||
codeCwd,
|
||||
codePolicy,
|
||||
middlePaneContext,
|
||||
});
|
||||
return id;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,29 @@
|
|||
import type { NotificationCategory } from '@x/shared/dist/notification-settings.js';
|
||||
import { isNotificationCategoryEnabled } from '../../config/notification_config.js';
|
||||
import type { INotificationService, NotifyInput } from './service.js';
|
||||
|
||||
/**
|
||||
* Fire a notification for `category`, but only if the user has that category
|
||||
* enabled and the platform supports notifications.
|
||||
*
|
||||
* Resolution of the notification service is done via a *dynamic* import of the
|
||||
* DI container so that callers like the agent runtime — which the container
|
||||
* itself imports — don't create a circular module dependency. The whole thing
|
||||
* is wrapped so a missing service (very early startup), an unsupported
|
||||
* platform, or a config read error can never disrupt the run/sync that
|
||||
* triggered it. Callers should fire-and-forget (`void notifyIfEnabled(...)`).
|
||||
*/
|
||||
export async function notifyIfEnabled(
|
||||
category: NotificationCategory,
|
||||
input: NotifyInput,
|
||||
): Promise<void> {
|
||||
try {
|
||||
if (!isNotificationCategoryEnabled(category)) return;
|
||||
const { default: container } = await import('../../di/container.js');
|
||||
const service = container.resolve<INotificationService>('notificationService');
|
||||
if (!service.isSupported()) return;
|
||||
service.notify(input);
|
||||
} catch (err) {
|
||||
console.error(`[notifier] failed to notify (category=${category}):`, err);
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,14 @@ export interface NotifyInput {
|
|||
link?: string;
|
||||
actionLabel?: string;
|
||||
secondaryActions?: Array<{ label: string; link: string }>;
|
||||
/**
|
||||
* When true, the notification is suppressed if the app is currently in the
|
||||
* foreground (any window focused). Use for ambient notifications the user
|
||||
* doesn't need while actively looking at the app (e.g. chat completion, new
|
||||
* email). Leave unset/false for notifications that must always surface
|
||||
* regardless of focus (e.g. an agent permission request that blocks a run).
|
||||
*/
|
||||
onlyWhenBackground?: boolean;
|
||||
}
|
||||
|
||||
export interface INotificationService {
|
||||
|
|
|
|||
|
|
@ -15,7 +15,8 @@ You are running with **no user present** to clarify, approve, or watch.
|
|||
|
||||
Your task folder is \`bg-tasks/<slug>/\` (the path is given in the run message). It contains:
|
||||
- \`task.yaml\` — the spec. **Never touch this.** The runtime owns it.
|
||||
- \`index.md\` — agent-owned. You read and write this freely via \`file-readText\` / \`file-editText\`.
|
||||
- \`index.md\` — the default agent-owned artifact (a note). You read and write it freely via \`file-readText\` / \`file-editText\`.
|
||||
- \`index.html\` — optional agent-owned artifact for **visual** output (see OUTPUT MODE). When it exists and is non-empty it is shown to the user instead of \`index.md\`.
|
||||
- \`runs/\` — your own run logs (jsonl). You don't write to it directly; the runtime does.
|
||||
|
||||
You can also read and write anywhere else under the workspace (\`knowledge/\`, etc.) when your instructions call for it.
|
||||
|
|
@ -28,6 +29,12 @@ Use when instructions imply a **current state** artifact:
|
|||
- "Keep me posted on …" / "What's the latest on …"
|
||||
On every run: \`file-readText\` \`index.md\`, decide the smallest patch that brings it into alignment with the instructions, apply with \`file-editText\`. Patch-style discipline: edit one region, re-read, then edit the next. Avoid one-shot rewrites.
|
||||
|
||||
Pick the artifact format from what the output needs:
|
||||
- **\`index.md\`** (default) — prose, lists, summaries, digests, briefs. Rendered as a styled note. Use patch-style edits as above.
|
||||
- **\`index.html\`** — when the output is inherently **visual**: a dashboard, a metrics table with conditional colors, a chart, a styled report — anything where layout/CSS carry meaning that a plain note would lose. Write a single **self-contained** file with \`file-writeText\` (inline all CSS and JS; avoid external/CDN dependencies as they may be blocked; reference only assets you save next to it in the task folder — relative paths resolve against the folder). It renders full-screen in a sandboxed iframe. HTML is typically regenerated wholesale each run, so a one-shot \`file-writeText\` is fine here.
|
||||
|
||||
Use ONE format per task — don't maintain both. \`index.html\` wins when present and non-empty. If you move a task from HTML back to a plain note, blank out \`index.html\` (\`file-writeText\` with \`""\`) so \`index.md\` shows again.
|
||||
|
||||
ACTION MODE — perform a side-effect, append a journal entry.
|
||||
Use when instructions imply a **recurring action**:
|
||||
- "Send / draft / post / notify / file / reply / publish / call / forward …"
|
||||
|
|
|
|||
|
|
@ -15,6 +15,13 @@ export interface RunPromptArgs {
|
|||
onEvent: (event: CodeRunEvent) => void;
|
||||
/** Aborts the turn on stop; the manager cancels then force-kills the adapter. */
|
||||
signal?: AbortSignal;
|
||||
/**
|
||||
* Drop the conversation replay that session/load streams on a cold resume.
|
||||
* Direct sessions persist their own history (run JSONL) and render from it,
|
||||
* so replaying through onEvent would duplicate every prior turn. When set,
|
||||
* events only flow to onEvent once the session is open, right before prompt.
|
||||
*/
|
||||
suppressReplay?: boolean;
|
||||
}
|
||||
|
||||
interface ActiveRun {
|
||||
|
|
@ -51,7 +58,7 @@ 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 { runId, agent, cwd, prompt, policy, ask, onEvent, signal, suppressReplay } = args;
|
||||
|
||||
const broker = new PermissionBroker({
|
||||
policy,
|
||||
|
|
@ -59,7 +66,7 @@ export class CodeModeManager {
|
|||
onResolved: (a, decision, auto) => onEvent({ type: 'permission', ask: a, decision, auto }),
|
||||
});
|
||||
|
||||
const run = await this.ensureRun(runId, agent, cwd, broker, onEvent);
|
||||
const run = await this.ensureRun(runId, agent, cwd, broker, onEvent, suppressReplay ?? false);
|
||||
run.inflight++;
|
||||
|
||||
let graceTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
|
@ -148,6 +155,7 @@ export class CodeModeManager {
|
|||
cwd: string,
|
||||
broker: PermissionBroker,
|
||||
onEvent: (event: CodeRunEvent) => void,
|
||||
suppressReplay: boolean,
|
||||
): Promise<ActiveRun> {
|
||||
const existing = this.runs.get(runId);
|
||||
if (existing && existing.agent === agent && existing.cwd === cwd) {
|
||||
|
|
@ -157,10 +165,19 @@ export class CodeModeManager {
|
|||
}
|
||||
if (existing) this.dispose(runId); // agent/cwd changed — start over
|
||||
|
||||
const client = new AcpClient({ agent, cwd, broker, onEvent });
|
||||
// With suppressReplay, the client starts with a muted event sink so a
|
||||
// session/load replay of the prior conversation goes nowhere; the real
|
||||
// sink is installed once the session is open (below).
|
||||
const client = new AcpClient({
|
||||
agent,
|
||||
cwd,
|
||||
broker,
|
||||
onEvent: suppressReplay ? () => {} : onEvent,
|
||||
});
|
||||
try {
|
||||
await client.start();
|
||||
const sessionId = await this.openSession(runId, agent, cwd, client);
|
||||
if (suppressReplay) client.setHandlers(broker, onEvent);
|
||||
const run: ActiveRun = { client, sessionId, agent, cwd, inflight: 0 };
|
||||
this.runs.set(runId, run);
|
||||
return run;
|
||||
|
|
|
|||
272
apps/x/packages/core/src/code-mode/git/service.ts
Normal file
272
apps/x/packages/core/src/code-mode/git/service.ts
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
import { execFile } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import type { GitRepoInfo, GitStatusFile, GitFileState } from '@x/shared/dist/code-sessions.js';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
// Plain shell-outs to the system git. isomorphic-git (already in core) doesn't
|
||||
// support worktrees, and these calls are simple enough that wrapping the CLI is
|
||||
// both lighter and more faithful to what the user's own git would do.
|
||||
|
||||
const MAX_BUFFER = 32 * 1024 * 1024;
|
||||
// Diff/file payloads above this are not worth shipping to the renderer.
|
||||
const MAX_TEXT_BYTES = 1024 * 1024;
|
||||
|
||||
async function git(cwd: string, args: string[]): Promise<string> {
|
||||
const { stdout } = await execFileAsync('git', args, { cwd, maxBuffer: MAX_BUFFER });
|
||||
return stdout;
|
||||
}
|
||||
|
||||
let gitAvailable: Promise<boolean> | null = null;
|
||||
export function isGitAvailable(): Promise<boolean> {
|
||||
if (!gitAvailable) {
|
||||
gitAvailable = execFileAsync('git', ['--version'], { timeout: 5000 })
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
}
|
||||
return gitAvailable;
|
||||
}
|
||||
|
||||
export async function repoInfo(cwd: string): Promise<GitRepoInfo> {
|
||||
const none: GitRepoInfo = { isGitRepo: false, branch: null, hasCommits: false, dirtyCount: 0 };
|
||||
if (!await isGitAvailable()) return none;
|
||||
try {
|
||||
const inside = (await git(cwd, ['rev-parse', '--is-inside-work-tree'])).trim();
|
||||
if (inside !== 'true') return none;
|
||||
} catch {
|
||||
return none;
|
||||
}
|
||||
let branch: string | null = null;
|
||||
try {
|
||||
branch = (await git(cwd, ['rev-parse', '--abbrev-ref', 'HEAD'])).trim() || null;
|
||||
} catch {
|
||||
// unborn branch (no commits) — symbolic-ref still knows the name
|
||||
try {
|
||||
const ref = (await git(cwd, ['symbolic-ref', 'HEAD'])).trim();
|
||||
branch = ref.replace(/^refs\/heads\//, '') || null;
|
||||
} catch {
|
||||
branch = null;
|
||||
}
|
||||
}
|
||||
let hasCommits = false;
|
||||
try {
|
||||
await git(cwd, ['rev-parse', '--verify', 'HEAD']);
|
||||
hasCommits = true;
|
||||
} catch {
|
||||
hasCommits = false;
|
||||
}
|
||||
let dirtyCount = 0;
|
||||
try {
|
||||
const out = await git(cwd, ['status', '--porcelain=v1', '-z']);
|
||||
dirtyCount = out.split('\0').filter((l) => l.trim() !== '').length;
|
||||
} catch {
|
||||
dirtyCount = 0;
|
||||
}
|
||||
return { isGitRepo: true, branch, hasCommits, dirtyCount };
|
||||
}
|
||||
|
||||
// git status/diff report paths relative to the REPO ROOT, which is not the
|
||||
// session cwd when the user opened a subdirectory of a repo as their project.
|
||||
// Disk reads must resolve against the root, not cwd.
|
||||
async function repoToplevel(cwd: string): Promise<string> {
|
||||
try {
|
||||
return (await git(cwd, ['rev-parse', '--show-toplevel'])).trim() || cwd;
|
||||
} catch {
|
||||
return cwd;
|
||||
}
|
||||
}
|
||||
|
||||
function stateFromPorcelain(xy: string): GitFileState {
|
||||
if (xy === '??') return 'untracked';
|
||||
if (xy.includes('R')) return 'renamed';
|
||||
if (xy.includes('A')) return 'added';
|
||||
if (xy.includes('D')) return 'deleted';
|
||||
return 'modified';
|
||||
}
|
||||
|
||||
// Working-tree changes vs HEAD with insertion/deletion counts, scoped to the
|
||||
// session directory's subtree (`-- .`): a project opened inside a bigger repo
|
||||
// only shows its own changes. Result paths are repo-root-relative (git's
|
||||
// porcelain format). Untracked files get their line count from disk (capped)
|
||||
// since numstat doesn't cover them.
|
||||
export async function status(cwd: string): Promise<GitStatusFile[]> {
|
||||
const root = await repoToplevel(cwd);
|
||||
const out = await git(cwd, ['status', '--porcelain=v1', '-z', '--', '.']);
|
||||
const entries: Array<{ path: string; state: GitFileState }> = [];
|
||||
// -z format: "XY path\0" and for renames "XY newPath\0oldPath\0"
|
||||
const parts = out.split('\0');
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const part = parts[i];
|
||||
if (!part || part.length < 4) continue;
|
||||
const xy = part.slice(0, 2);
|
||||
const filePath = part.slice(3);
|
||||
const state = stateFromPorcelain(xy);
|
||||
if (state === 'renamed') i++; // skip the old path that follows
|
||||
entries.push({ path: filePath, state });
|
||||
}
|
||||
|
||||
const counts = new Map<string, { insertions: number | null; deletions: number | null }>();
|
||||
try {
|
||||
const numstat = await git(cwd, ['diff', 'HEAD', '--numstat', '-z', '--', '.']);
|
||||
// -z numstat rows: "ins\tdel\tpath\0" (renames: "ins\tdel\0old\0new\0")
|
||||
const rows = numstat.split('\0');
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const row = rows[i];
|
||||
if (!row) continue;
|
||||
const m = row.match(/^(\d+|-)\t(\d+|-)\t?(.*)$/);
|
||||
if (!m) continue;
|
||||
const insertions = m[1] === '-' ? null : Number(m[1]);
|
||||
const deletions = m[2] === '-' ? null : Number(m[2]);
|
||||
let filePath = m[3];
|
||||
if (!filePath) {
|
||||
// rename form: old and new paths follow as separate tokens
|
||||
i += 2;
|
||||
filePath = rows[i] ?? '';
|
||||
}
|
||||
if (filePath) counts.set(filePath, { insertions, deletions });
|
||||
}
|
||||
} catch {
|
||||
// no HEAD yet (no commits) — leave counts empty
|
||||
}
|
||||
|
||||
const result: GitStatusFile[] = [];
|
||||
for (const entry of entries) {
|
||||
let insertions: number | null = null;
|
||||
let deletions: number | null = null;
|
||||
const counted = counts.get(entry.path);
|
||||
if (counted) {
|
||||
insertions = counted.insertions;
|
||||
deletions = counted.deletions;
|
||||
} else if (entry.state === 'untracked') {
|
||||
try {
|
||||
const full = path.join(root, entry.path);
|
||||
const stat = await fs.stat(full);
|
||||
if (stat.isFile() && stat.size <= MAX_TEXT_BYTES) {
|
||||
const content = await fs.readFile(full, 'utf8');
|
||||
if (!content.includes('\0')) {
|
||||
insertions = content.length === 0
|
||||
? 0
|
||||
: content.split('\n').length - (content.endsWith('\n') ? 1 : 0);
|
||||
deletions = 0;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// unreadable — leave counts null
|
||||
}
|
||||
}
|
||||
result.push({ path: entry.path, state: entry.state, insertions, deletions });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export interface FileDiff {
|
||||
oldText: string;
|
||||
newText: string;
|
||||
isBinary: boolean;
|
||||
tooLarge: boolean;
|
||||
}
|
||||
|
||||
export async function fileDiff(cwd: string, relPath: string): Promise<FileDiff> {
|
||||
// Paths from `status` are repo-root-relative; paths clicked in the chat
|
||||
// timeline are cwd-relative. Resolve whichever interpretation points at a
|
||||
// real file (deleted files fall back to the root interpretation, which is
|
||||
// also what `git show` uses).
|
||||
const root = await repoToplevel(cwd);
|
||||
let gitPath = relPath;
|
||||
let full = path.join(root, relPath);
|
||||
const existsAt = async (p: string) => fs.stat(p).then((s) => s.isFile()).catch(() => false);
|
||||
if (!await existsAt(full)) {
|
||||
const cwdFull = path.join(cwd, relPath);
|
||||
if (await existsAt(cwdFull)) {
|
||||
full = cwdFull;
|
||||
// Realpath both sides — git reports the real toplevel, while the
|
||||
// session cwd may reach it through a symlink (e.g. /tmp on macOS).
|
||||
const realFull = await fs.realpath(cwdFull).catch(() => cwdFull);
|
||||
gitPath = path.relative(root, realFull).split(path.sep).join('/');
|
||||
}
|
||||
}
|
||||
let oldText = '';
|
||||
try {
|
||||
oldText = await git(cwd, ['show', `HEAD:${gitPath}`]);
|
||||
} catch {
|
||||
// untracked / newly added / no commits — diff against empty
|
||||
oldText = '';
|
||||
}
|
||||
let newText = '';
|
||||
try {
|
||||
const stat = await fs.stat(full);
|
||||
if (stat.size > MAX_TEXT_BYTES) {
|
||||
return { oldText: '', newText: '', isBinary: false, tooLarge: true };
|
||||
}
|
||||
newText = await fs.readFile(full, 'utf8');
|
||||
} catch {
|
||||
// deleted from working tree
|
||||
newText = '';
|
||||
}
|
||||
if (oldText.length > MAX_TEXT_BYTES) {
|
||||
return { oldText: '', newText: '', isBinary: false, tooLarge: true };
|
||||
}
|
||||
if (oldText.includes('\0') || newText.includes('\0')) {
|
||||
return { oldText: '', newText: '', isBinary: true, tooLarge: false };
|
||||
}
|
||||
return { oldText, newText, isBinary: false, tooLarge: false };
|
||||
}
|
||||
|
||||
export async function worktreeAdd(repoPath: string, worktreePath: string, branch: string): Promise<void> {
|
||||
await fs.mkdir(path.dirname(worktreePath), { recursive: true });
|
||||
await git(repoPath, ['worktree', 'add', '-b', branch, worktreePath, 'HEAD']);
|
||||
}
|
||||
|
||||
export async function worktreeRemove(
|
||||
repoPath: string,
|
||||
worktreePath: string,
|
||||
opts: { force?: boolean; deleteBranch?: string } = {},
|
||||
): Promise<void> {
|
||||
try {
|
||||
const args = ['worktree', 'remove'];
|
||||
if (opts.force) args.push('--force');
|
||||
args.push(worktreePath);
|
||||
await git(repoPath, args);
|
||||
} catch {
|
||||
// The worktree dir may have been deleted by hand — prune the registration.
|
||||
await git(repoPath, ['worktree', 'prune']).catch(() => {});
|
||||
}
|
||||
if (opts.deleteBranch) {
|
||||
await git(repoPath, ['branch', '-D', opts.deleteBranch]).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
export interface MergeBackResult {
|
||||
ok: boolean;
|
||||
conflict?: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
// Merge the session branch into whatever the original checkout currently has
|
||||
// checked out. Refuses on a dirty checkout; aborts cleanly on conflicts.
|
||||
export async function mergeBack(repoPath: string, branch: string): Promise<MergeBackResult> {
|
||||
const info = await repoInfo(repoPath);
|
||||
if (!info.isGitRepo) {
|
||||
return { ok: false, message: 'The project folder is not a git repository.' };
|
||||
}
|
||||
if (info.dirtyCount > 0) {
|
||||
return {
|
||||
ok: false,
|
||||
message: `The repository at ${repoPath} has ${info.dirtyCount} uncommitted change(s). Commit or stash them, then merge again — or merge manually with: git merge ${branch}`,
|
||||
};
|
||||
}
|
||||
try {
|
||||
await git(repoPath, ['merge', '--no-edit', branch]);
|
||||
return { ok: true, message: `Merged ${branch} into ${info.branch ?? 'the current branch'}.` };
|
||||
} catch (e) {
|
||||
await git(repoPath, ['merge', '--abort']).catch(() => {});
|
||||
const detail = e instanceof Error ? e.message : String(e);
|
||||
return {
|
||||
ok: false,
|
||||
conflict: true,
|
||||
message: `Merge of ${branch} hit conflicts and was aborted. Resolve manually with: git merge ${branch}\n\n${detail.slice(0, 600)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
83
apps/x/packages/core/src/code-mode/projects/fs.ts
Normal file
83
apps/x/packages/core/src/code-mode/projects/fs.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
|
||||
// Contained file browsing for the Code section. Session cwds are arbitrary
|
||||
// user directories (outside the Rowboat workspace), so every access resolves
|
||||
// against the session root and is validated to stay inside it — realpath on
|
||||
// the containing directory defeats both `..` traversal and symlink escapes.
|
||||
|
||||
const MAX_FILE_BYTES = 1024 * 1024;
|
||||
|
||||
async function resolveContained(root: string, relPath: string): Promise<string> {
|
||||
if (path.isAbsolute(relPath)) {
|
||||
throw new Error('Absolute paths are not allowed');
|
||||
}
|
||||
const realRoot = await fs.realpath(root);
|
||||
const resolved = path.resolve(realRoot, relPath);
|
||||
// Realpath the parent so symlinked ancestors can't escape...
|
||||
const realParent = await fs.realpath(path.dirname(resolved)).catch(() => null);
|
||||
if (realParent === null) {
|
||||
throw new Error(`No such directory: ${relPath}`);
|
||||
}
|
||||
// ...and the target itself, so the final component being a symlink
|
||||
// (e.g. a link to /etc) can't either. A missing target keeps its own path.
|
||||
const joined = path.join(realParent, path.basename(resolved));
|
||||
const realTarget = await fs.realpath(joined).catch(() => joined);
|
||||
if (realTarget !== realRoot && !realTarget.startsWith(realRoot + path.sep)) {
|
||||
throw new Error('Path escapes the session directory');
|
||||
}
|
||||
return realTarget;
|
||||
}
|
||||
|
||||
export interface ProjectDirEntry {
|
||||
name: string;
|
||||
kind: 'file' | 'dir';
|
||||
size?: number;
|
||||
}
|
||||
|
||||
// One level at a time — the tree lazily expands, so node_modules costs nothing
|
||||
// until the user opens it. `.git` is always hidden.
|
||||
export async function readProjectDir(root: string, relPath: string): Promise<ProjectDirEntry[]> {
|
||||
const target = await resolveContained(root, relPath || '.');
|
||||
const dirents = await fs.readdir(target, { withFileTypes: true });
|
||||
const entries: ProjectDirEntry[] = [];
|
||||
for (const d of dirents) {
|
||||
if (d.name === '.git') continue;
|
||||
if (d.isDirectory()) {
|
||||
entries.push({ name: d.name, kind: 'dir' });
|
||||
} else if (d.isFile()) {
|
||||
let size: number | undefined;
|
||||
try {
|
||||
size = (await fs.stat(path.join(target, d.name))).size;
|
||||
} catch {
|
||||
size = undefined;
|
||||
}
|
||||
entries.push({ name: d.name, kind: 'file', size });
|
||||
}
|
||||
// symlinks and other entry kinds are skipped
|
||||
}
|
||||
entries.sort((a, b) => (a.kind === b.kind ? a.name.localeCompare(b.name) : a.kind === 'dir' ? -1 : 1));
|
||||
return entries;
|
||||
}
|
||||
|
||||
export interface ProjectFileContent {
|
||||
content: string;
|
||||
isBinary: boolean;
|
||||
tooLarge: boolean;
|
||||
}
|
||||
|
||||
export async function readProjectFile(root: string, relPath: string): Promise<ProjectFileContent> {
|
||||
const target = await resolveContained(root, relPath);
|
||||
const stat = await fs.stat(target);
|
||||
if (!stat.isFile()) {
|
||||
throw new Error(`Not a file: ${relPath}`);
|
||||
}
|
||||
if (stat.size > MAX_FILE_BYTES) {
|
||||
return { content: '', isBinary: false, tooLarge: true };
|
||||
}
|
||||
const buf = await fs.readFile(target);
|
||||
if (buf.includes(0)) {
|
||||
return { content: '', isBinary: true, tooLarge: false };
|
||||
}
|
||||
return { content: buf.toString('utf8'), isBinary: false, tooLarge: false };
|
||||
}
|
||||
69
apps/x/packages/core/src/code-mode/projects/repo.ts
Normal file
69
apps/x/packages/core/src/code-mode/projects/repo.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { WorkDir } from '../../config/config.js';
|
||||
import z from 'zod';
|
||||
import { CodeProject } from '@x/shared/dist/code-sessions.js';
|
||||
|
||||
const ProjectsFile = z.object({
|
||||
projects: z.array(CodeProject),
|
||||
});
|
||||
|
||||
export interface ICodeProjectsRepo {
|
||||
list(): Promise<CodeProject[]>;
|
||||
get(projectId: string): Promise<CodeProject | null>;
|
||||
add(dirPath: string): Promise<CodeProject>;
|
||||
remove(projectId: string): Promise<void>;
|
||||
}
|
||||
|
||||
// Registered project directories for the Code section. One small JSON file —
|
||||
// same pattern as the other config repos.
|
||||
export class FSCodeProjectsRepo implements ICodeProjectsRepo {
|
||||
private readonly configPath = path.join(WorkDir, 'config', 'code-projects.json');
|
||||
|
||||
private async read(): Promise<CodeProject[]> {
|
||||
try {
|
||||
const raw = await fs.readFile(this.configPath, 'utf8');
|
||||
return ProjectsFile.parse(JSON.parse(raw)).projects;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private async write(projects: CodeProject[]): Promise<void> {
|
||||
await fs.mkdir(path.dirname(this.configPath), { recursive: true });
|
||||
await fs.writeFile(this.configPath, JSON.stringify({ projects }, null, 2));
|
||||
}
|
||||
|
||||
async list(): Promise<CodeProject[]> {
|
||||
return this.read();
|
||||
}
|
||||
|
||||
async get(projectId: string): Promise<CodeProject | null> {
|
||||
const projects = await this.read();
|
||||
return projects.find((p) => p.id === projectId) ?? null;
|
||||
}
|
||||
|
||||
async add(dirPath: string): Promise<CodeProject> {
|
||||
const resolved = path.resolve(dirPath);
|
||||
const stat = await fs.stat(resolved);
|
||||
if (!stat.isDirectory()) {
|
||||
throw new Error(`Not a directory: ${resolved}`);
|
||||
}
|
||||
const projects = await this.read();
|
||||
const existing = projects.find((p) => p.path === resolved);
|
||||
if (existing) return existing;
|
||||
const project: CodeProject = {
|
||||
id: `proj-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
path: resolved,
|
||||
name: path.basename(resolved),
|
||||
addedAt: new Date().toISOString(),
|
||||
};
|
||||
await this.write([...projects, project]);
|
||||
return project;
|
||||
}
|
||||
|
||||
async remove(projectId: string): Promise<void> {
|
||||
const projects = await this.read();
|
||||
await this.write(projects.filter((p) => p.id !== projectId));
|
||||
}
|
||||
}
|
||||
63
apps/x/packages/core/src/code-mode/sessions/repo.ts
Normal file
63
apps/x/packages/core/src/code-mode/sessions/repo.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { WorkDir } from '../../config/config.js';
|
||||
import { CodeSession } from '@x/shared/dist/code-sessions.js';
|
||||
|
||||
// Mutable metadata for Code-section sessions, one JSON file per session
|
||||
// (keyed by the session/run id). The immutable conversation itself lives in
|
||||
// the run JSONL; the ACP resume state lives in code-mode/sessions/.
|
||||
const META_DIR = path.join(WorkDir, 'code-mode', 'sessions-meta');
|
||||
|
||||
function metaFile(sessionId: string): string {
|
||||
return path.join(META_DIR, `${sessionId}.json`);
|
||||
}
|
||||
|
||||
export interface ICodeSessionsRepo {
|
||||
list(): Promise<CodeSession[]>;
|
||||
get(sessionId: string): Promise<CodeSession | null>;
|
||||
save(session: CodeSession): Promise<void>;
|
||||
remove(sessionId: string): Promise<void>;
|
||||
}
|
||||
|
||||
export class FSCodeSessionsRepo implements ICodeSessionsRepo {
|
||||
async list(): Promise<CodeSession[]> {
|
||||
let names: string[] = [];
|
||||
try {
|
||||
names = (await fs.readdir(META_DIR)).filter((n) => n.endsWith('.json'));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const sessions: CodeSession[] = [];
|
||||
for (const name of names) {
|
||||
try {
|
||||
const raw = await fs.readFile(path.join(META_DIR, name), 'utf8');
|
||||
sessions.push(CodeSession.parse(JSON.parse(raw)));
|
||||
} catch {
|
||||
// skip malformed files
|
||||
}
|
||||
}
|
||||
// Newest activity first; session ids are time-sortable as a tiebreaker.
|
||||
sessions.sort((a, b) =>
|
||||
(b.lastActivityAt ?? b.createdAt).localeCompare(a.lastActivityAt ?? a.createdAt));
|
||||
return sessions;
|
||||
}
|
||||
|
||||
async get(sessionId: string): Promise<CodeSession | null> {
|
||||
try {
|
||||
const raw = await fs.readFile(metaFile(sessionId), 'utf8');
|
||||
return CodeSession.parse(JSON.parse(raw));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async save(session: CodeSession): Promise<void> {
|
||||
const validated = CodeSession.parse(session);
|
||||
await fs.mkdir(META_DIR, { recursive: true });
|
||||
await fs.writeFile(metaFile(validated.id), JSON.stringify(validated, null, 2));
|
||||
}
|
||||
|
||||
async remove(sessionId: string): Promise<void> {
|
||||
await fs.rm(metaFile(sessionId), { force: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
361
apps/x/packages/core/src/code-mode/sessions/service.ts
Normal file
361
apps/x/packages/core/src/code-mode/sessions/service.ts
Normal file
|
|
@ -0,0 +1,361 @@
|
|||
import path from 'path';
|
||||
import fs from 'fs/promises';
|
||||
import z from 'zod';
|
||||
import { WorkDir } from '../../config/config.js';
|
||||
import type { CodeSession, CodeSessionMode } from '@x/shared/dist/code-sessions.js';
|
||||
import type { CodingAgent, ApprovalPolicy } from '@x/shared/dist/code-mode.js';
|
||||
import { RunEvent, MessageEvent } from '@x/shared/dist/runs.js';
|
||||
import type { IRunsRepo } from '../../runs/repo.js';
|
||||
import type { IRunsLock } from '../../runs/lock.js';
|
||||
import type { IBus } from '../../application/lib/bus.js';
|
||||
import type { IMonotonicallyIncreasingIdGenerator } from '../../application/lib/id-gen.js';
|
||||
import type { IAbortRegistry } from '../../runs/abort-registry.js';
|
||||
import type { CodeModeManager } from '../acp/manager.js';
|
||||
import type { CodePermissionRegistry } from '../acp/permission-registry.js';
|
||||
import type { ICodeSessionsRepo } from './repo.js';
|
||||
import type { ICodeProjectsRepo } from '../projects/repo.js';
|
||||
import { clearStoredSession } from '../acp/session-store.js';
|
||||
import * as gitService from '../git/service.js';
|
||||
|
||||
export interface CreateSessionArgs {
|
||||
projectId: string;
|
||||
title?: string;
|
||||
agent: CodingAgent;
|
||||
mode: CodeSessionMode;
|
||||
policy: ApprovalPolicy;
|
||||
isolation: 'in-repo' | 'worktree';
|
||||
// LLM for Rowboat-mode turns; unset falls through to the configured default.
|
||||
model?: string;
|
||||
provider?: string;
|
||||
}
|
||||
|
||||
export interface SendMessageResult {
|
||||
accepted: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function worktreeRoot(projectId: string, sessionId: string): string {
|
||||
return path.join(WorkDir, 'code-mode', 'worktrees', projectId, sessionId);
|
||||
}
|
||||
|
||||
// The per-run work directory the copilot anchors its general context to
|
||||
// (same file the chat composer writes for regular chats). Keeping it in sync
|
||||
// with the session cwd means Rowboat-mode turns see the right "# User Work
|
||||
// Directory" even for tools other than code_agent_run.
|
||||
async function persistRunWorkDir(runId: string, cwd: string): Promise<void> {
|
||||
try {
|
||||
const file = path.join(WorkDir, 'config', `workdir-${runId}.json`);
|
||||
await fs.writeFile(file, JSON.stringify({ path: cwd }, null, 2));
|
||||
} catch {
|
||||
// best effort — the session meta still pins cwd for code_agent_run
|
||||
}
|
||||
}
|
||||
|
||||
// Drives Code-section sessions. A session is a run (same id) whose JSONL holds
|
||||
// both modes' history: Rowboat turns are written by the agent runtime; direct
|
||||
// turns are written here. The direct path talks straight to the ACP engine —
|
||||
// no copilot LLM in between — but mirrors the runtime's lifecycle contract
|
||||
// (runs lock, abort registry, processing-start/end, run-stopped) so the rest
|
||||
// of the app (stop IPC, status tracking, event forwarding) needs no special
|
||||
// casing.
|
||||
export class CodeSessionService {
|
||||
private readonly runsRepo: IRunsRepo;
|
||||
private readonly runsLock: IRunsLock;
|
||||
private readonly bus: IBus;
|
||||
private readonly idGenerator: IMonotonicallyIncreasingIdGenerator;
|
||||
private readonly abortRegistry: IAbortRegistry;
|
||||
private readonly codeModeManager: CodeModeManager;
|
||||
private readonly codePermissionRegistry: CodePermissionRegistry;
|
||||
private readonly codeSessionsRepo: ICodeSessionsRepo;
|
||||
private readonly codeProjectsRepo: ICodeProjectsRepo;
|
||||
// Session ids with a direct prompt currently streaming (the runs lock also
|
||||
// guards this, but we keep our own set to give a precise "busy" error).
|
||||
private readonly inflight = new Set<string>();
|
||||
|
||||
constructor({
|
||||
runsRepo,
|
||||
runsLock,
|
||||
bus,
|
||||
idGenerator,
|
||||
abortRegistry,
|
||||
codeModeManager,
|
||||
codePermissionRegistry,
|
||||
codeSessionsRepo,
|
||||
codeProjectsRepo,
|
||||
}: {
|
||||
runsRepo: IRunsRepo;
|
||||
runsLock: IRunsLock;
|
||||
bus: IBus;
|
||||
idGenerator: IMonotonicallyIncreasingIdGenerator;
|
||||
abortRegistry: IAbortRegistry;
|
||||
codeModeManager: CodeModeManager;
|
||||
codePermissionRegistry: CodePermissionRegistry;
|
||||
codeSessionsRepo: ICodeSessionsRepo;
|
||||
codeProjectsRepo: ICodeProjectsRepo;
|
||||
}) {
|
||||
this.runsRepo = runsRepo;
|
||||
this.runsLock = runsLock;
|
||||
this.bus = bus;
|
||||
this.idGenerator = idGenerator;
|
||||
this.abortRegistry = abortRegistry;
|
||||
this.codeModeManager = codeModeManager;
|
||||
this.codePermissionRegistry = codePermissionRegistry;
|
||||
this.codeSessionsRepo = codeSessionsRepo;
|
||||
this.codeProjectsRepo = codeProjectsRepo;
|
||||
}
|
||||
|
||||
async create(args: CreateSessionArgs): Promise<CodeSession> {
|
||||
const project = await this.codeProjectsRepo.get(args.projectId);
|
||||
if (!project) throw new Error(`Unknown project: ${args.projectId}`);
|
||||
|
||||
// The session is a real run so Rowboat mode (agent runtime) works on it
|
||||
// directly and the existing runs plumbing (fetch/events/stop) applies.
|
||||
const { createRun } = await import('../../runs/runs.js');
|
||||
const run = await createRun({
|
||||
agentId: 'copilot',
|
||||
useCase: 'code_session',
|
||||
...(args.model ? { model: args.model } : {}),
|
||||
...(args.provider ? { provider: args.provider } : {}),
|
||||
});
|
||||
const sessionId = run.id;
|
||||
|
||||
let cwd = project.path;
|
||||
let worktree: CodeSession['worktree'];
|
||||
if (args.isolation === 'worktree') {
|
||||
const info = await gitService.repoInfo(project.path);
|
||||
if (!info.isGitRepo || !info.hasCommits) {
|
||||
throw new Error('Worktree isolation needs a git repository with at least one commit.');
|
||||
}
|
||||
const branch = `rowboat/${sessionId}`;
|
||||
const wtPath = worktreeRoot(project.id, sessionId);
|
||||
await gitService.worktreeAdd(project.path, wtPath, branch);
|
||||
worktree = { path: wtPath, branch, baseBranch: info.branch };
|
||||
cwd = wtPath;
|
||||
}
|
||||
|
||||
const session: CodeSession = {
|
||||
id: sessionId,
|
||||
projectId: project.id,
|
||||
title: args.title?.trim() || `${project.name} session`,
|
||||
agent: args.agent,
|
||||
mode: args.mode,
|
||||
policy: args.policy,
|
||||
cwd,
|
||||
...(worktree ? { worktree } : {}),
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
await this.codeSessionsRepo.save(session);
|
||||
await persistRunWorkDir(sessionId, cwd);
|
||||
return session;
|
||||
}
|
||||
|
||||
async update(sessionId: string, patch: Partial<Pick<CodeSession, 'title' | 'mode' | 'policy' | 'agent'>>): Promise<CodeSession> {
|
||||
const session = await this.codeSessionsRepo.get(sessionId);
|
||||
if (!session) throw new Error(`Unknown session: ${sessionId}`);
|
||||
const updated: CodeSession = { ...session, ...patch };
|
||||
await this.codeSessionsRepo.save(updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
isBusy(sessionId: string): boolean {
|
||||
return this.inflight.has(sessionId);
|
||||
}
|
||||
|
||||
// Direct drive: send the user's text straight to the session's ACP agent.
|
||||
// Returns once the turn fully settles (the renderer streams via runs:events).
|
||||
async sendMessage(sessionId: string, text: string): Promise<SendMessageResult> {
|
||||
const session = await this.codeSessionsRepo.get(sessionId);
|
||||
if (!session) return { accepted: false, error: `Unknown session: ${sessionId}` };
|
||||
if (this.inflight.has(sessionId)) {
|
||||
return { accepted: false, error: 'The agent is still working on the previous message.' };
|
||||
}
|
||||
// The runs lock is shared with the agent runtime, so a Rowboat-mode turn
|
||||
// in flight blocks direct sends (and vice versa) — the run JSONL never
|
||||
// interleaves two writers.
|
||||
if (!await this.runsLock.lock(sessionId)) {
|
||||
return { accepted: false, error: 'The session is busy with a Rowboat-driven turn.' };
|
||||
}
|
||||
this.inflight.add(sessionId);
|
||||
const signal = this.abortRegistry.createForRun(sessionId);
|
||||
const turnId = await this.idGenerator.next();
|
||||
const toolCallId = `direct-${turnId}`;
|
||||
|
||||
const appendAndPublish = async (event: z.infer<typeof RunEvent>) => {
|
||||
await this.runsRepo.appendEvents(sessionId, [event]);
|
||||
await this.bus.publish(event);
|
||||
};
|
||||
|
||||
try {
|
||||
await this.bus.publish({ runId: sessionId, type: 'run-processing-start', subflow: [] });
|
||||
|
||||
const userEvent: z.infer<typeof MessageEvent> = {
|
||||
runId: sessionId,
|
||||
type: 'message',
|
||||
messageId: await this.idGenerator.next(),
|
||||
message: { role: 'user', content: text },
|
||||
subflow: [],
|
||||
ts: new Date().toISOString(),
|
||||
};
|
||||
await appendAndPublish(userEvent);
|
||||
await this.touch(session);
|
||||
|
||||
// Stream events live; persist the structural ones (tool calls, plan,
|
||||
// resolved permissions). Streaming `message` chunks are NOT persisted —
|
||||
// the agent's full text lands as one assistant MessageEvent below, which
|
||||
// is also what lets a later Rowboat-mode turn see this conversation.
|
||||
let finalText = '';
|
||||
const persistQueue: Array<z.infer<typeof RunEvent>> = [];
|
||||
const onAbort = () => this.codePermissionRegistry.cancelRun(sessionId);
|
||||
if (signal.aborted) onAbort();
|
||||
else signal.addEventListener('abort', onAbort, { once: true });
|
||||
|
||||
let stopReason = 'cancelled';
|
||||
try {
|
||||
const result = await this.codeModeManager.runPrompt({
|
||||
runId: sessionId,
|
||||
agent: session.agent,
|
||||
cwd: session.cwd,
|
||||
prompt: text,
|
||||
policy: session.policy,
|
||||
signal,
|
||||
suppressReplay: true,
|
||||
onEvent: (event) => {
|
||||
if (event.type === 'message' && event.role === 'agent') finalText += event.text;
|
||||
const streamEvent: z.infer<typeof RunEvent> = {
|
||||
runId: sessionId,
|
||||
type: 'code-run-event',
|
||||
toolCallId,
|
||||
event,
|
||||
subflow: [],
|
||||
};
|
||||
void this.bus.publish(streamEvent);
|
||||
if (event.type === 'tool_call' || event.type === 'tool_call_update'
|
||||
|| event.type === 'plan' || event.type === 'permission') {
|
||||
persistQueue.push({ ...streamEvent, ts: new Date().toISOString() });
|
||||
}
|
||||
},
|
||||
ask: (permAsk) => this.codePermissionRegistry.request(sessionId, (requestId) => {
|
||||
void this.bus.publish({
|
||||
runId: sessionId,
|
||||
type: 'code-run-permission-request',
|
||||
toolCallId,
|
||||
requestId,
|
||||
ask: permAsk,
|
||||
subflow: [],
|
||||
});
|
||||
}),
|
||||
});
|
||||
stopReason = result.stopReason;
|
||||
} catch (error) {
|
||||
if (!signal.aborted) {
|
||||
const message = error instanceof Error ? (error.message || error.name) : String(error);
|
||||
await appendAndPublish({ runId: sessionId, type: 'error', error: message, subflow: [] });
|
||||
}
|
||||
} finally {
|
||||
signal.removeEventListener('abort', onAbort);
|
||||
}
|
||||
|
||||
if (persistQueue.length > 0) {
|
||||
await this.runsRepo.appendEvents(sessionId, persistQueue);
|
||||
}
|
||||
if (finalText.trim()) {
|
||||
await appendAndPublish({
|
||||
runId: sessionId,
|
||||
type: 'message',
|
||||
messageId: await this.idGenerator.next(),
|
||||
message: { role: 'assistant', content: finalText },
|
||||
subflow: [],
|
||||
ts: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
if (signal.aborted || stopReason === 'cancelled') {
|
||||
await appendAndPublish({
|
||||
runId: sessionId,
|
||||
type: 'run-stopped',
|
||||
reason: 'user-requested',
|
||||
subflow: [],
|
||||
});
|
||||
}
|
||||
await this.touch(session);
|
||||
return { accepted: true };
|
||||
} finally {
|
||||
this.inflight.delete(sessionId);
|
||||
this.abortRegistry.cleanup(sessionId);
|
||||
await this.runsLock.release(sessionId);
|
||||
await this.bus.publish({ runId: sessionId, type: 'run-processing-end', subflow: [] });
|
||||
}
|
||||
}
|
||||
|
||||
// Unblocks a stuck permission card immediately; the manager's signal handling
|
||||
// (ACP cancel -> grace -> force-kill) actually unwinds the prompt.
|
||||
async stop(sessionId: string): Promise<void> {
|
||||
this.abortRegistry.abort(sessionId);
|
||||
this.codePermissionRegistry.cancelRun(sessionId);
|
||||
}
|
||||
|
||||
async mergeBack(sessionId: string): Promise<gitService.MergeBackResult> {
|
||||
const session = await this.codeSessionsRepo.get(sessionId);
|
||||
if (!session?.worktree) {
|
||||
return { ok: false, message: 'This session has no isolated worktree to merge.' };
|
||||
}
|
||||
const project = await this.codeProjectsRepo.get(session.projectId);
|
||||
if (!project) {
|
||||
return { ok: false, message: 'The session\'s project is no longer registered.' };
|
||||
}
|
||||
const result = await gitService.mergeBack(project.path, session.worktree.branch);
|
||||
if (result.ok) {
|
||||
await this.codeSessionsRepo.save({
|
||||
...session,
|
||||
worktree: { ...session.worktree, mergedAt: new Date().toISOString() },
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async cleanupWorktree(sessionId: string, deleteBranch: boolean): Promise<void> {
|
||||
const session = await this.codeSessionsRepo.get(sessionId);
|
||||
if (!session?.worktree || session.worktree.removedAt) return;
|
||||
const project = await this.codeProjectsRepo.get(session.projectId);
|
||||
// Drop any live agent connection on the worktree before deleting it.
|
||||
this.codeModeManager.dispose(sessionId);
|
||||
if (project) {
|
||||
await gitService.worktreeRemove(project.path, session.worktree.path, {
|
||||
force: true,
|
||||
...(deleteBranch ? { deleteBranch: session.worktree.branch } : {}),
|
||||
});
|
||||
}
|
||||
const nextCwd = project?.path ?? session.cwd;
|
||||
await this.codeSessionsRepo.save({
|
||||
...session,
|
||||
// The worktree is gone — fall back to working directly in the repo.
|
||||
cwd: nextCwd,
|
||||
worktree: { ...session.worktree, removedAt: new Date().toISOString() },
|
||||
});
|
||||
await persistRunWorkDir(sessionId, nextCwd);
|
||||
}
|
||||
|
||||
async delete(sessionId: string, opts: { removeWorktree?: boolean; deleteBranch?: boolean } = {}): Promise<void> {
|
||||
await this.stop(sessionId);
|
||||
this.codeModeManager.dispose(sessionId);
|
||||
const session = await this.codeSessionsRepo.get(sessionId);
|
||||
if (opts.removeWorktree && session?.worktree && !session.worktree.removedAt) {
|
||||
const project = await this.codeProjectsRepo.get(session.projectId);
|
||||
if (project) {
|
||||
await gitService.worktreeRemove(project.path, session.worktree.path, {
|
||||
force: true,
|
||||
...(opts.deleteBranch ? { deleteBranch: session.worktree.branch } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
await clearStoredSession(sessionId);
|
||||
await this.codeSessionsRepo.remove(sessionId);
|
||||
await this.runsRepo.delete(sessionId).catch(() => {});
|
||||
await fs.rm(path.join(WorkDir, 'config', `workdir-${sessionId}.json`), { force: true }).catch(() => {});
|
||||
}
|
||||
|
||||
private async touch(session: CodeSession): Promise<void> {
|
||||
const current = await this.codeSessionsRepo.get(session.id);
|
||||
if (!current) return;
|
||||
await this.codeSessionsRepo.save({ ...current, lastActivityAt: new Date().toISOString() });
|
||||
}
|
||||
}
|
||||
136
apps/x/packages/core/src/code-mode/sessions/status-tracker.ts
Normal file
136
apps/x/packages/core/src/code-mode/sessions/status-tracker.ts
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
import z from 'zod';
|
||||
import { RunEvent } from '@x/shared/dist/runs.js';
|
||||
import type { IBus } from '../../application/lib/bus.js';
|
||||
import type { ICodeSessionsRepo } from './repo.js';
|
||||
import type { INotificationService } from '../../application/notification/service.js';
|
||||
import type { CodeSessionStatus, CodeSession } from '@x/shared/dist/code-sessions.js';
|
||||
import container from '../../di/container.js';
|
||||
|
||||
export type StatusListener = (sessionId: string, status: CodeSessionStatus) => void;
|
||||
|
||||
// Authoritative live status for Code-section sessions, derived in the main
|
||||
// process from the run event stream. Works for both modes uniformly because
|
||||
// direct turns and Rowboat-mode code_agent_run turns publish the same event
|
||||
// types on the bus. The renderer just renders what this pushes.
|
||||
export class CodeSessionStatusTracker {
|
||||
private readonly bus: IBus;
|
||||
private readonly codeSessionsRepo: ICodeSessionsRepo;
|
||||
private readonly statuses = new Map<string, CodeSessionStatus>();
|
||||
private readonly busySince = new Map<string, number>();
|
||||
private readonly listeners = new Set<StatusListener>();
|
||||
private unsubscribe: (() => void) | null = null;
|
||||
// Session ids known to be code sessions; refreshed lazily on unknown ids so
|
||||
// sessions created after start() are picked up without explicit wiring.
|
||||
private knownSessions = new Set<string>();
|
||||
// Ids confirmed NOT to be sessions (regular chat runs). Safe to cache
|
||||
// permanently: a session's meta file is written before its first turn, so
|
||||
// an id that misses the refresh can never become a session later.
|
||||
private readonly knownNonSessions = new Set<string>();
|
||||
|
||||
constructor({ bus, codeSessionsRepo }: { bus: IBus; codeSessionsRepo: ICodeSessionsRepo }) {
|
||||
this.bus = bus;
|
||||
this.codeSessionsRepo = codeSessionsRepo;
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (this.unsubscribe) return;
|
||||
await this.refreshKnownSessions();
|
||||
this.unsubscribe = await this.bus.subscribe('*', async (event) => {
|
||||
await this.handle(event);
|
||||
});
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.unsubscribe?.();
|
||||
this.unsubscribe = null;
|
||||
}
|
||||
|
||||
onTransition(listener: StatusListener): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
getStatuses(): Record<string, CodeSessionStatus> {
|
||||
return Object.fromEntries(this.statuses);
|
||||
}
|
||||
|
||||
private async refreshKnownSessions(): Promise<void> {
|
||||
const sessions = await this.codeSessionsRepo.list().catch(() => [] as CodeSession[]);
|
||||
this.knownSessions = new Set(sessions.map((s) => s.id));
|
||||
}
|
||||
|
||||
private async isCodeSession(runId: string): Promise<boolean> {
|
||||
if (this.knownSessions.has(runId)) return true;
|
||||
if (this.knownNonSessions.has(runId)) return false;
|
||||
// Unknown id — maybe a session created since the last refresh.
|
||||
await this.refreshKnownSessions();
|
||||
if (this.knownSessions.has(runId)) return true;
|
||||
this.knownNonSessions.add(runId);
|
||||
return false;
|
||||
}
|
||||
|
||||
private async handle(event: z.infer<typeof RunEvent>): Promise<void> {
|
||||
const relevant = event.type === 'run-processing-start'
|
||||
|| event.type === 'run-processing-end'
|
||||
|| event.type === 'run-stopped'
|
||||
|| event.type === 'error'
|
||||
|| event.type === 'code-run-permission-request'
|
||||
|| (event.type === 'code-run-event' && event.event.type === 'permission');
|
||||
if (!relevant) return;
|
||||
if (!await this.isCodeSession(event.runId)) return;
|
||||
|
||||
const previous = this.statuses.get(event.runId) ?? 'idle';
|
||||
let next: CodeSessionStatus = previous;
|
||||
switch (event.type) {
|
||||
case 'run-processing-start':
|
||||
next = 'working';
|
||||
break;
|
||||
case 'code-run-permission-request':
|
||||
next = 'needs-you';
|
||||
break;
|
||||
case 'code-run-event':
|
||||
// A permission resolution while the turn is still running.
|
||||
if (previous === 'needs-you') next = 'working';
|
||||
break;
|
||||
case 'run-processing-end':
|
||||
case 'run-stopped':
|
||||
case 'error':
|
||||
next = 'idle';
|
||||
break;
|
||||
}
|
||||
if (next === previous) return;
|
||||
if (previous === 'idle' && next !== 'idle') this.busySince.set(event.runId, Date.now());
|
||||
this.statuses.set(event.runId, next);
|
||||
for (const listener of this.listeners) listener(event.runId, next);
|
||||
await this.notify(event.runId, previous, next);
|
||||
if (next === 'idle') this.busySince.delete(event.runId);
|
||||
}
|
||||
|
||||
private async notify(sessionId: string, previous: CodeSessionStatus, next: CodeSessionStatus): Promise<void> {
|
||||
let notificationService: INotificationService;
|
||||
try {
|
||||
notificationService = container.resolve<INotificationService>('notificationService');
|
||||
} catch {
|
||||
return; // not registered (e.g. tests)
|
||||
}
|
||||
if (!notificationService.isSupported()) return;
|
||||
const session = await this.codeSessionsRepo.get(sessionId);
|
||||
const title = session?.title ?? 'Coding session';
|
||||
if (next === 'needs-you') {
|
||||
notificationService.notify({
|
||||
title,
|
||||
message: 'The coding agent needs your approval.',
|
||||
});
|
||||
} else if (next === 'idle' && previous === 'working') {
|
||||
// Only worth interrupting for if the agent worked long enough that
|
||||
// the user has plausibly moved on to something else.
|
||||
const since = this.busySince.get(sessionId);
|
||||
if (since !== undefined && Date.now() - since > 30_000) {
|
||||
notificationService.notify({
|
||||
title,
|
||||
message: 'The coding agent finished its turn.',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
52
apps/x/packages/core/src/config/notification_config.ts
Normal file
52
apps/x/packages/core/src/config/notification_config.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import {
|
||||
NotificationSettingsSchema,
|
||||
DEFAULT_NOTIFICATION_SETTINGS,
|
||||
type NotificationSettings,
|
||||
type NotificationCategory,
|
||||
} from '@x/shared/dist/notification-settings.js';
|
||||
import { WorkDir } from './config.js';
|
||||
|
||||
const NOTIFICATION_CONFIG_PATH = path.join(WorkDir, 'config', 'notification_settings.json');
|
||||
|
||||
/**
|
||||
* Load notification settings, merging any persisted values over the defaults.
|
||||
*
|
||||
* Merging (rather than a strict parse) keeps the file forward/backward
|
||||
* compatible: a category added in a newer build is filled in from defaults
|
||||
* when an older file omits it, and a malformed file falls back to defaults
|
||||
* instead of disabling notifications entirely.
|
||||
*/
|
||||
export function loadNotificationSettings(): NotificationSettings {
|
||||
try {
|
||||
if (fs.existsSync(NOTIFICATION_CONFIG_PATH)) {
|
||||
const content = fs.readFileSync(NOTIFICATION_CONFIG_PATH, 'utf-8');
|
||||
const parsed = JSON.parse(content);
|
||||
const categories = parsed?.categories ?? {};
|
||||
return NotificationSettingsSchema.parse({
|
||||
categories: {
|
||||
...DEFAULT_NOTIFICATION_SETTINGS.categories,
|
||||
...categories,
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[NotificationConfig] Error loading notification settings:', error);
|
||||
}
|
||||
return DEFAULT_NOTIFICATION_SETTINGS;
|
||||
}
|
||||
|
||||
export function saveNotificationSettings(settings: NotificationSettings): void {
|
||||
const dir = path.dirname(NOTIFICATION_CONFIG_PATH);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
const validated = NotificationSettingsSchema.parse(settings);
|
||||
fs.writeFileSync(NOTIFICATION_CONFIG_PATH, JSON.stringify(validated, null, 2));
|
||||
}
|
||||
|
||||
/** Convenience: is a single notification category currently enabled? */
|
||||
export function isNotificationCategoryEnabled(category: NotificationCategory): boolean {
|
||||
return loadNotificationSettings().categories[category];
|
||||
}
|
||||
|
|
@ -18,6 +18,10 @@ import { FSAgentScheduleStateRepo, IAgentScheduleStateRepo } from "../agent-sche
|
|||
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 { FSCodeProjectsRepo, ICodeProjectsRepo } from "../code-mode/projects/repo.js";
|
||||
import { FSCodeSessionsRepo, ICodeSessionsRepo } from "../code-mode/sessions/repo.js";
|
||||
import { CodeSessionService } from "../code-mode/sessions/service.js";
|
||||
import { CodeSessionStatusTracker } from "../code-mode/sessions/status-tracker.js";
|
||||
import type { IBrowserControlService } from "../application/browser-control/service.js";
|
||||
import type { INotificationService } from "../application/notification/service.js";
|
||||
|
||||
|
|
@ -51,6 +55,13 @@ container.register({
|
|||
// session/load); the registry brokers mid-run approvals.
|
||||
codeModeManager: asClass(CodeModeManager).singleton(),
|
||||
codePermissionRegistry: asClass(CodePermissionRegistry).singleton(),
|
||||
|
||||
// Code section: project registry, session metadata, the direct-drive
|
||||
// session service, and the live status tracker.
|
||||
codeProjectsRepo: asClass<ICodeProjectsRepo>(FSCodeProjectsRepo).singleton(),
|
||||
codeSessionsRepo: asClass<ICodeSessionsRepo>(FSCodeSessionsRepo).singleton(),
|
||||
codeSessionService: asClass(CodeSessionService).singleton(),
|
||||
codeSessionStatusTracker: asClass(CodeSessionStatusTracker).singleton(),
|
||||
});
|
||||
|
||||
export default container;
|
||||
|
|
|
|||
348
apps/x/packages/core/src/knowledge/gmail_contacts.ts
Normal file
348
apps/x/packages/core/src/knowledge/gmail_contacts.ts
Normal file
|
|
@ -0,0 +1,348 @@
|
|||
import fs from 'fs';
|
||||
import fsp from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { WorkDir } from '../config/config.js';
|
||||
import type { GmailThreadSnapshot } from './sync_gmail.js';
|
||||
import { getAccountEmail } from './sync_gmail.js';
|
||||
|
||||
const CACHE_DIR = path.join(WorkDir, 'inbox_lists');
|
||||
const INDEX_TTL_MS = 5 * 60 * 1000;
|
||||
const RECENCY_HALFLIFE_DAYS = 60;
|
||||
const READ_CONCURRENCY = 16;
|
||||
|
||||
export interface Contact {
|
||||
name: string;
|
||||
email: string;
|
||||
count: number;
|
||||
lastSeenMs: number;
|
||||
}
|
||||
|
||||
interface IndexEntry {
|
||||
name: string;
|
||||
email: string;
|
||||
count: number;
|
||||
lastSeenMs: number;
|
||||
nameCounts: Map<string, number>;
|
||||
}
|
||||
|
||||
let cachedIndex: Map<string, IndexEntry> | null = null;
|
||||
let cachedAt = 0;
|
||||
let pendingRebuild: Promise<Map<string, IndexEntry>> | null = null;
|
||||
|
||||
function parseAddressList(header: string): Array<{ name: string; email: string }> {
|
||||
if (!header) return [];
|
||||
const parts: string[] = [];
|
||||
let buf = '';
|
||||
let inQuotes = false;
|
||||
let inBrackets = 0;
|
||||
for (const ch of header) {
|
||||
if (ch === '"' && inBrackets === 0) inQuotes = !inQuotes;
|
||||
else if (ch === '<') inBrackets++;
|
||||
else if (ch === '>') inBrackets = Math.max(0, inBrackets - 1);
|
||||
if (ch === ',' && !inQuotes && inBrackets === 0) {
|
||||
if (buf.trim()) parts.push(buf.trim());
|
||||
buf = '';
|
||||
} else {
|
||||
buf += ch;
|
||||
}
|
||||
}
|
||||
if (buf.trim()) parts.push(buf.trim());
|
||||
|
||||
const result: Array<{ name: string; email: string }> = [];
|
||||
for (const part of parts) {
|
||||
const angled = part.match(/^(.*?)<\s*([^>]+?)\s*>\s*$/);
|
||||
if (angled) {
|
||||
const name = angled[1].trim().replace(/^"|"$/g, '').trim();
|
||||
const email = angled[2].trim().toLowerCase();
|
||||
if (email.includes('@')) result.push({ name, email });
|
||||
} else if (part.includes('@')) {
|
||||
result.push({ name: '', email: part.trim().toLowerCase() });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Local-part aliases that are almost always automated/role addresses you don't
|
||||
// compose a fresh message to. Matched as a whole segment of the local part
|
||||
// (segments split on . _ - +).
|
||||
const AUTOMATED_LOCAL_PARTS = new Set([
|
||||
'noreply', 'no-reply', 'donotreply', 'do-not-reply', 'reply',
|
||||
'notifications', 'notification', 'notify',
|
||||
'alerts', 'alert', 'updates', 'update',
|
||||
'news', 'newsletter', 'newsletters',
|
||||
'info', 'information', 'hello', 'hi', 'hey',
|
||||
'welcome', 'onboarding', 'getstarted',
|
||||
'team', 'marketing', 'promo', 'promos', 'promotions',
|
||||
'offer', 'offers', 'deals', 'deal',
|
||||
'accounts', 'account', 'billing', 'invoices', 'statements', 'statement',
|
||||
'learn', 'learning', 'courses',
|
||||
'mailer-daemon', 'mailerdaemon', 'postmaster', 'bounce', 'bounces',
|
||||
'automated', 'auto', 'autoconfirm',
|
||||
'support-bot', 'noticeboard', 'system',
|
||||
'contact', 'connect',
|
||||
'sender', 'broadcast', 'digest', 'campaign', 'campaigns',
|
||||
'support', 'service', 'help', 'helpdesk', 'feedback',
|
||||
'mailer', 'mailers', 'members', 'membership',
|
||||
'careers', 'jobs', 'recruit', 'recruiting',
|
||||
'tickets', 'orders', 'order', 'receipts', 'receipt',
|
||||
'applications', 'apply', 'admissions',
|
||||
'health', 'security', 'auth',
|
||||
]);
|
||||
|
||||
// Subdomain labels that flag a bulk/marketing infrastructure domain.
|
||||
const AUTOMATED_SUBDOMAIN_LABELS = new Set([
|
||||
'mail', 'mailer', 'mailers', 'mailing', 'mailgun', 'sendgrid', 'mta',
|
||||
'email', 'em', 'e', 'm',
|
||||
'news', 'newsletter', 'newsletters',
|
||||
'marketing', 'mkt', 'promo', 'promos', 'offers',
|
||||
'event', 'events', 'ecomm', 'commerce',
|
||||
'notifications', 'notification', 'notify', 'alerts', 'alert', 'updates',
|
||||
'messaging', 'message', 'msg',
|
||||
'noreply', 'donotreply',
|
||||
'creators', 'partners', 'team',
|
||||
'info', 'welcome', 'hi', 'hello',
|
||||
'bounces', 'bounce',
|
||||
'reply', 'user', 'usr', 'auto',
|
||||
]);
|
||||
|
||||
// Specific bulk-mail provider domains (substring match on full domain).
|
||||
const AUTOMATED_DOMAIN_KEYWORDS = [
|
||||
'facebookmail', 'kajabimail', 'substack', 'mailgun', 'sendgrid',
|
||||
'mcsv.net', 'mailchimp', 'mailerlite', 'createsend', 'cmail',
|
||||
'amazonses', 'sparkpost', 'sendinblue', 'brevo',
|
||||
'luma-mail', 'lumamail',
|
||||
'umusic-online', 'icloud-mail',
|
||||
];
|
||||
|
||||
function localSegments(local: string): string[] {
|
||||
return local.toLowerCase().split(/[._\-+]/).filter(Boolean);
|
||||
}
|
||||
|
||||
function isAutomatedAddress(email: string): boolean {
|
||||
if (!email) return true;
|
||||
const at = email.indexOf('@');
|
||||
if (at < 0) return true;
|
||||
const local = email.slice(0, at).toLowerCase();
|
||||
const domain = email.slice(at + 1).toLowerCase();
|
||||
|
||||
// Plus-aliased reply bots: `reply+abc123@…`
|
||||
if (/^reply\+/i.test(local)) return true;
|
||||
|
||||
// Whole-segment local-part matches.
|
||||
const segs = localSegments(local);
|
||||
for (const s of segs) {
|
||||
if (AUTOMATED_LOCAL_PARTS.has(s)) return true;
|
||||
}
|
||||
// Some senders pack noise into the local part with no separators
|
||||
// (e.g. `hdfcbanksmartstatement`). Catch the common ones.
|
||||
if (/(no.?reply|do.?not.?reply|notifications?|news.?letter|mailer.?daemon|postmaster|automated|broadcast|statement)/i.test(local)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Random-looking machine local parts: long, mostly hex/base32-ish.
|
||||
if (local.length >= 20 && /^[a-z0-9]+(-[a-z0-9]+)*$/.test(local) && /[0-9]/.test(local)) {
|
||||
const digits = (local.match(/[0-9]/g) || []).length;
|
||||
if (digits / local.length >= 0.25) return true;
|
||||
}
|
||||
|
||||
// Subdomain-label check (everything except the registrable last two labels).
|
||||
const labels = domain.split('.');
|
||||
if (labels.length >= 3) {
|
||||
const subs = labels.slice(0, -2);
|
||||
for (const label of subs) {
|
||||
if (AUTOMATED_SUBDOMAIN_LABELS.has(label)) return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Provider keyword anywhere in the domain.
|
||||
for (const kw of AUTOMATED_DOMAIN_KEYWORDS) {
|
||||
if (domain.includes(kw)) return true;
|
||||
}
|
||||
|
||||
// Domain itself contains tell-tale tokens.
|
||||
if (/(^|\.)(mailers?|mailer|mailgun|sendgrid|mailchimp|mailerlite|bounces?|marketing|promo|notifications?|newsletter)(\.|$)/i.test(domain)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Marketing-style TLD / second-level domain (e.g. bookmyshow.email,
|
||||
// foo.marketing, bar.news). These domains exist almost exclusively for bulk.
|
||||
const sld = labels[labels.length - 1];
|
||||
if (['email', 'mail', 'marketing', 'promo', 'news', 'newsletter', 'click', 'link'].includes(sld)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Brand-identity addresses like `uber@uber.com`, `lenovo@lenovo.com` —
|
||||
// local part equals the first label of the domain. Almost always a
|
||||
// transactional/marketing sender.
|
||||
if (labels.length >= 2 && local === labels[0]) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function ingestSnapshot(snapshot: GmailThreadSnapshot, selfEmail: string, map: Map<string, IndexEntry>): void {
|
||||
if (!snapshot?.messages) return;
|
||||
for (const msg of snapshot.messages) {
|
||||
const parsed = msg.date ? Date.parse(msg.date) : NaN;
|
||||
const ts = Number.isFinite(parsed) ? parsed : 0;
|
||||
const fromAddrs = msg.from ? parseAddressList(msg.from) : [];
|
||||
const sentBySelf = fromAddrs.some((a) => a.email === selfEmail);
|
||||
|
||||
// Collect candidate contacts. For outbound mail, take recipients (the
|
||||
// people *you* chose to write to — highest signal). For inbound mail,
|
||||
// take the sender, but only if it doesn't look like a no-reply bot.
|
||||
const candidates: Array<{ name: string; email: string }> = [];
|
||||
if (sentBySelf) {
|
||||
for (const h of [msg.to, msg.cc].filter(Boolean) as string[]) {
|
||||
candidates.push(...parseAddressList(h));
|
||||
}
|
||||
} else {
|
||||
for (const a of fromAddrs) candidates.push(a);
|
||||
}
|
||||
|
||||
for (const { name, email } of candidates) {
|
||||
if (!email || email === selfEmail) continue;
|
||||
if (isAutomatedAddress(email)) continue;
|
||||
let entry = map.get(email);
|
||||
if (!entry) {
|
||||
entry = { name, email, count: 0, lastSeenMs: 0, nameCounts: new Map() };
|
||||
map.set(email, entry);
|
||||
}
|
||||
// Sent-to addresses carry stronger signal than inbound senders.
|
||||
entry.count += sentBySelf ? 3 : 1;
|
||||
if (ts > entry.lastSeenMs) entry.lastSeenMs = ts;
|
||||
if (name) entry.nameCounts.set(name, (entry.nameCounts.get(name) || 0) + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function rebuildIndex(): Promise<Map<string, IndexEntry>> {
|
||||
const map = new Map<string, IndexEntry>();
|
||||
if (!fs.existsSync(CACHE_DIR)) return map;
|
||||
|
||||
// Without a self email we can't tell which messages were sent by the user,
|
||||
// so the index stays empty until Gmail is connected.
|
||||
const selfRaw = await getAccountEmail().catch(() => null);
|
||||
if (!selfRaw) return map;
|
||||
const selfEmail = selfRaw.trim().toLowerCase();
|
||||
|
||||
let names: string[];
|
||||
try {
|
||||
names = await fsp.readdir(CACHE_DIR);
|
||||
} catch {
|
||||
return map;
|
||||
}
|
||||
|
||||
const files = names.filter((n) => n.endsWith('.json'));
|
||||
// Cap concurrency so a huge inbox can't blow the FD table.
|
||||
for (let i = 0; i < files.length; i += READ_CONCURRENCY) {
|
||||
const slice = files.slice(i, i + READ_CONCURRENCY);
|
||||
const chunks = await Promise.all(
|
||||
slice.map(async (fname) => {
|
||||
try {
|
||||
return await fsp.readFile(path.join(CACHE_DIR, fname), 'utf-8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
);
|
||||
for (const raw of chunks) {
|
||||
if (!raw) continue;
|
||||
try {
|
||||
const wrapper = JSON.parse(raw) as { snapshot?: GmailThreadSnapshot };
|
||||
if (wrapper.snapshot) ingestSnapshot(wrapper.snapshot, selfEmail, map);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const entry of map.values()) {
|
||||
let best = entry.name;
|
||||
let bestN = 0;
|
||||
for (const [n, c] of entry.nameCounts) {
|
||||
if (c > bestN) { best = n; bestN = c; }
|
||||
}
|
||||
entry.name = best;
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
async function getIndex(): Promise<Map<string, IndexEntry>> {
|
||||
const now = Date.now();
|
||||
const fresh = cachedIndex && now - cachedAt <= INDEX_TTL_MS;
|
||||
if (fresh) return cachedIndex!;
|
||||
|
||||
// Serve stale cache while a refresh runs in the background; only block when
|
||||
// there's no cache at all.
|
||||
if (!pendingRebuild) {
|
||||
pendingRebuild = rebuildIndex().then((m) => {
|
||||
cachedIndex = m;
|
||||
cachedAt = Date.now();
|
||||
pendingRebuild = null;
|
||||
return m;
|
||||
}).catch((err) => {
|
||||
pendingRebuild = null;
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
if (cachedIndex) return cachedIndex;
|
||||
return pendingRebuild;
|
||||
}
|
||||
|
||||
export function invalidateContactIndex(): void {
|
||||
cachedIndex = null;
|
||||
cachedAt = 0;
|
||||
}
|
||||
|
||||
// Warm the cache eagerly so the first user keystroke doesn't pay the cost.
|
||||
export function warmContactIndex(): void {
|
||||
void getIndex().catch(() => {});
|
||||
}
|
||||
|
||||
function score(entry: IndexEntry, nowMs: number): number {
|
||||
const days = Math.max(0, (nowMs - entry.lastSeenMs) / (1000 * 60 * 60 * 24));
|
||||
const recency = Math.pow(0.5, days / RECENCY_HALFLIFE_DAYS);
|
||||
return entry.count * (0.5 + 0.5 * recency);
|
||||
}
|
||||
|
||||
function matchTier(q: string, entry: IndexEntry): number {
|
||||
if (!q) return 3;
|
||||
const name = entry.name.toLowerCase();
|
||||
const email = entry.email;
|
||||
if (name && name.startsWith(q)) return 0;
|
||||
if (email.startsWith(q)) return 1;
|
||||
if (name && name.includes(' ' + q)) return 1;
|
||||
if (name && name.includes(q)) return 2;
|
||||
if (email.includes(q)) return 3;
|
||||
return -1;
|
||||
}
|
||||
|
||||
export interface SearchOpts {
|
||||
limit?: number;
|
||||
excludeEmails?: string[];
|
||||
}
|
||||
|
||||
export async function searchContacts(query: string, opts: SearchOpts = {}): Promise<Contact[]> {
|
||||
const q = query.trim().toLowerCase();
|
||||
const limit = Math.max(1, Math.min(50, opts.limit ?? 8));
|
||||
const excluded = new Set((opts.excludeEmails ?? []).map((e) => e.trim().toLowerCase()));
|
||||
|
||||
const index = await getIndex();
|
||||
const nowMs = Date.now();
|
||||
const matches: Array<{ entry: IndexEntry; tier: number; s: number }> = [];
|
||||
for (const entry of index.values()) {
|
||||
if (excluded.has(entry.email)) continue;
|
||||
const tier = matchTier(q, entry);
|
||||
if (tier < 0) continue;
|
||||
matches.push({ entry, tier, s: score(entry, nowMs) });
|
||||
}
|
||||
matches.sort((a, b) => (a.tier - b.tier) || (b.s - a.s));
|
||||
return matches.slice(0, limit).map(({ entry }) => ({
|
||||
name: entry.name,
|
||||
email: entry.email,
|
||||
count: entry.count,
|
||||
lastSeenMs: entry.lastSeenMs,
|
||||
}));
|
||||
}
|
||||
388
apps/x/packages/core/src/knowledge/gmail_sent_contacts.ts
Normal file
388
apps/x/packages/core/src/knowledge/gmail_sent_contacts.ts
Normal file
|
|
@ -0,0 +1,388 @@
|
|||
import fs from 'fs';
|
||||
import fsp from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { google, gmail_v1 as gmail } from 'googleapis';
|
||||
import { OAuth2Client } from 'google-auth-library';
|
||||
import { WorkDir } from '../config/config.js';
|
||||
import { GoogleClientFactory } from './google-client-factory.js';
|
||||
import { getUserEmail } from './classify_thread.js';
|
||||
|
||||
const STATE_FILE = path.join(WorkDir, 'contacts_sent.json');
|
||||
const RECENCY_HALFLIFE_DAYS = 60;
|
||||
const HEADER_FETCH_CONCURRENCY = 8;
|
||||
const REFRESH_INTERVAL_MS = 30 * 60 * 1000;
|
||||
|
||||
export interface Contact {
|
||||
name: string;
|
||||
email: string;
|
||||
count: number;
|
||||
lastSeenMs: number;
|
||||
}
|
||||
|
||||
interface StoredEntry {
|
||||
name: string;
|
||||
email: string;
|
||||
count: number;
|
||||
lastSeenMs: number;
|
||||
nameCounts: Record<string, number>;
|
||||
}
|
||||
|
||||
interface StoredState {
|
||||
version: 1;
|
||||
historyId: string | null;
|
||||
selfEmail: string | null;
|
||||
lastFullSyncAt: number;
|
||||
entries: StoredEntry[];
|
||||
}
|
||||
|
||||
interface IndexEntry {
|
||||
name: string;
|
||||
email: string;
|
||||
count: number;
|
||||
lastSeenMs: number;
|
||||
nameCounts: Map<string, number>;
|
||||
}
|
||||
|
||||
let cachedIndex: Map<string, IndexEntry> | null = null;
|
||||
let lastRefreshAt = 0;
|
||||
let pendingSync: Promise<void> | null = null;
|
||||
|
||||
// Parses an address-list header value, respecting quoted display names and
|
||||
// angle brackets ("Last, First" <a@b>, …).
|
||||
function parseAddressList(header: string): Array<{ name: string; email: string }> {
|
||||
if (!header) return [];
|
||||
const parts: string[] = [];
|
||||
let buf = '';
|
||||
let inQuotes = false;
|
||||
let inBrackets = 0;
|
||||
for (const ch of header) {
|
||||
if (ch === '"' && inBrackets === 0) inQuotes = !inQuotes;
|
||||
else if (ch === '<') inBrackets++;
|
||||
else if (ch === '>') inBrackets = Math.max(0, inBrackets - 1);
|
||||
if (ch === ',' && !inQuotes && inBrackets === 0) {
|
||||
if (buf.trim()) parts.push(buf.trim());
|
||||
buf = '';
|
||||
} else {
|
||||
buf += ch;
|
||||
}
|
||||
}
|
||||
if (buf.trim()) parts.push(buf.trim());
|
||||
|
||||
const out: Array<{ name: string; email: string }> = [];
|
||||
for (const part of parts) {
|
||||
const angled = part.match(/^(.*?)<\s*([^>]+?)\s*>\s*$/);
|
||||
if (angled) {
|
||||
const name = angled[1].trim().replace(/^"|"$/g, '').trim();
|
||||
const email = angled[2].trim().toLowerCase();
|
||||
if (email.includes('@')) out.push({ name, email });
|
||||
} else if (part.includes('@')) {
|
||||
out.push({ name: '', email: part.trim().toLowerCase() });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function loadState(): StoredState | null {
|
||||
try {
|
||||
if (!fs.existsSync(STATE_FILE)) return null;
|
||||
const raw = fs.readFileSync(STATE_FILE, 'utf-8');
|
||||
const parsed = JSON.parse(raw) as StoredState;
|
||||
if (parsed.version !== 1) return null;
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveState(state: StoredState): Promise<void> {
|
||||
const tmp = STATE_FILE + '.tmp';
|
||||
await fsp.mkdir(path.dirname(STATE_FILE), { recursive: true });
|
||||
await fsp.writeFile(tmp, JSON.stringify(state), 'utf-8');
|
||||
await fsp.rename(tmp, STATE_FILE);
|
||||
}
|
||||
|
||||
function indexFromStored(state: StoredState): Map<string, IndexEntry> {
|
||||
const map = new Map<string, IndexEntry>();
|
||||
for (const e of state.entries) {
|
||||
map.set(e.email, {
|
||||
name: e.name,
|
||||
email: e.email,
|
||||
count: e.count,
|
||||
lastSeenMs: e.lastSeenMs,
|
||||
nameCounts: new Map(Object.entries(e.nameCounts || {})),
|
||||
});
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function storedFromIndex(map: Map<string, IndexEntry>, historyId: string | null, selfEmail: string | null, lastFullSyncAt: number): StoredState {
|
||||
const entries: StoredEntry[] = [];
|
||||
for (const e of map.values()) {
|
||||
entries.push({
|
||||
name: e.name,
|
||||
email: e.email,
|
||||
count: e.count,
|
||||
lastSeenMs: e.lastSeenMs,
|
||||
nameCounts: Object.fromEntries(e.nameCounts),
|
||||
});
|
||||
}
|
||||
return { version: 1, historyId, selfEmail, lastFullSyncAt, entries };
|
||||
}
|
||||
|
||||
function promoteCanonicalNames(map: Map<string, IndexEntry>): void {
|
||||
for (const entry of map.values()) {
|
||||
let best = entry.name;
|
||||
let bestN = 0;
|
||||
for (const [n, c] of entry.nameCounts) {
|
||||
if (c > bestN) { best = n; bestN = c; }
|
||||
}
|
||||
entry.name = best;
|
||||
}
|
||||
}
|
||||
|
||||
// Pulls the To/Cc/Date headers for a single sent message and folds the parsed
|
||||
// recipients into the index.
|
||||
async function ingestMessage(
|
||||
client: gmail.Gmail,
|
||||
messageId: string,
|
||||
selfEmail: string,
|
||||
map: Map<string, IndexEntry>,
|
||||
): Promise<void> {
|
||||
const res = await client.users.messages.get({
|
||||
userId: 'me',
|
||||
id: messageId,
|
||||
format: 'metadata',
|
||||
metadataHeaders: ['To', 'Cc', 'Date'],
|
||||
});
|
||||
const headers = res.data.payload?.headers ?? [];
|
||||
const headerValue = (name: string) => headers.find((h) => h.name?.toLowerCase() === name.toLowerCase())?.value ?? '';
|
||||
|
||||
const dateStr = headerValue('Date');
|
||||
const parsedDate = dateStr ? Date.parse(dateStr) : NaN;
|
||||
const ts = Number.isFinite(parsedDate) ? parsedDate : Date.now();
|
||||
|
||||
const recipients = [
|
||||
...parseAddressList(headerValue('To')),
|
||||
...parseAddressList(headerValue('Cc')),
|
||||
];
|
||||
for (const { name, email } of recipients) {
|
||||
if (!email || email === selfEmail) continue;
|
||||
let entry = map.get(email);
|
||||
if (!entry) {
|
||||
entry = { name, email, count: 0, lastSeenMs: 0, nameCounts: new Map() };
|
||||
map.set(email, entry);
|
||||
}
|
||||
entry.count++;
|
||||
if (ts > entry.lastSeenMs) entry.lastSeenMs = ts;
|
||||
if (name) entry.nameCounts.set(name, (entry.nameCounts.get(name) || 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
async function processInBatches<T>(items: T[], size: number, fn: (item: T) => Promise<void>): Promise<void> {
|
||||
for (let i = 0; i < items.length; i += size) {
|
||||
const slice = items.slice(i, i + size);
|
||||
await Promise.all(slice.map(async (item) => {
|
||||
try { await fn(item); }
|
||||
catch { /* skip failed individual messages */ }
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
async function fullSync(auth: OAuth2Client, selfEmail: string): Promise<{ map: Map<string, IndexEntry>; historyId: string | null }> {
|
||||
const client = google.gmail({ version: 'v1', auth });
|
||||
|
||||
// Lock in the current historyId BEFORE we start listing, so any messages
|
||||
// sent during the sync get caught by the next incremental run.
|
||||
let startingHistoryId: string | null = null;
|
||||
try {
|
||||
const profile = await client.users.getProfile({ userId: 'me' });
|
||||
startingHistoryId = profile.data.historyId ?? null;
|
||||
} catch {
|
||||
startingHistoryId = null;
|
||||
}
|
||||
|
||||
const messageIds: string[] = [];
|
||||
let pageToken: string | undefined;
|
||||
do {
|
||||
const res = await client.users.messages.list({
|
||||
userId: 'me',
|
||||
labelIds: ['SENT'],
|
||||
maxResults: 500,
|
||||
pageToken,
|
||||
});
|
||||
for (const m of res.data.messages ?? []) {
|
||||
if (m.id) messageIds.push(m.id);
|
||||
}
|
||||
pageToken = res.data.nextPageToken ?? undefined;
|
||||
} while (pageToken);
|
||||
|
||||
const map = new Map<string, IndexEntry>();
|
||||
await processInBatches(messageIds, HEADER_FETCH_CONCURRENCY, (id) => ingestMessage(client, id, selfEmail, map));
|
||||
promoteCanonicalNames(map);
|
||||
return { map, historyId: startingHistoryId };
|
||||
}
|
||||
|
||||
async function incrementalSync(
|
||||
auth: OAuth2Client,
|
||||
selfEmail: string,
|
||||
startHistoryId: string,
|
||||
map: Map<string, IndexEntry>,
|
||||
): Promise<{ historyId: string | null; added: number } | null> {
|
||||
const client = google.gmail({ version: 'v1', auth });
|
||||
const added: string[] = [];
|
||||
let pageToken: string | undefined;
|
||||
let latestHistoryId: string | null = null;
|
||||
try {
|
||||
do {
|
||||
const res = await client.users.history.list({
|
||||
userId: 'me',
|
||||
startHistoryId,
|
||||
labelId: 'SENT',
|
||||
historyTypes: ['messageAdded'],
|
||||
maxResults: 500,
|
||||
pageToken,
|
||||
});
|
||||
for (const h of res.data.history ?? []) {
|
||||
for (const m of h.messagesAdded ?? []) {
|
||||
const labels = m.message?.labelIds ?? [];
|
||||
const id = m.message?.id;
|
||||
if (id && labels.includes('SENT')) added.push(id);
|
||||
}
|
||||
}
|
||||
if (res.data.historyId) latestHistoryId = res.data.historyId;
|
||||
pageToken = res.data.nextPageToken ?? undefined;
|
||||
} while (pageToken);
|
||||
} catch (err: unknown) {
|
||||
// 404 means startHistoryId is too old — caller should fall back to full sync.
|
||||
const status = (err as { code?: number; status?: number })?.code ?? (err as { code?: number; status?: number })?.status;
|
||||
if (status === 404) return null;
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Dedupe in case the same message shows up in multiple history pages.
|
||||
const unique = Array.from(new Set(added));
|
||||
await processInBatches(unique, HEADER_FETCH_CONCURRENCY, (id) => ingestMessage(client, id, selfEmail, map));
|
||||
if (unique.length > 0) promoteCanonicalNames(map);
|
||||
|
||||
// If history.list returned no entries we have no fresh historyId; keep
|
||||
// using the watermark we started from so the next call retries the same window.
|
||||
return { historyId: latestHistoryId ?? startHistoryId, added: unique.length };
|
||||
}
|
||||
|
||||
async function performSync(): Promise<void> {
|
||||
const auth = await GoogleClientFactory.getClient();
|
||||
if (!auth) return;
|
||||
const selfRaw = await getUserEmail(auth).catch(() => null);
|
||||
if (!selfRaw) return;
|
||||
const selfEmail = selfRaw.trim().toLowerCase();
|
||||
|
||||
const stored = loadState();
|
||||
const sameAccount = stored?.selfEmail === selfEmail;
|
||||
|
||||
if (stored && sameAccount && stored.historyId) {
|
||||
const map = indexFromStored(stored);
|
||||
const result = await incrementalSync(auth, selfEmail, stored.historyId, map);
|
||||
if (result) {
|
||||
cachedIndex = map;
|
||||
await saveState(storedFromIndex(map, result.historyId, selfEmail, stored.lastFullSyncAt));
|
||||
lastRefreshAt = Date.now();
|
||||
return;
|
||||
}
|
||||
// history watermark too old → fall through to full sync.
|
||||
}
|
||||
|
||||
const { map, historyId } = await fullSync(auth, selfEmail);
|
||||
cachedIndex = map;
|
||||
await saveState(storedFromIndex(map, historyId, selfEmail, Date.now()));
|
||||
lastRefreshAt = Date.now();
|
||||
}
|
||||
|
||||
function ensureFresh(): void {
|
||||
if (pendingSync) return;
|
||||
if (Date.now() - lastRefreshAt < REFRESH_INTERVAL_MS) return;
|
||||
pendingSync = performSync()
|
||||
.catch((err) => {
|
||||
console.error('[gmail_sent_contacts] sync failed:', err instanceof Error ? err.message : err);
|
||||
})
|
||||
.finally(() => {
|
||||
pendingSync = null;
|
||||
});
|
||||
}
|
||||
|
||||
// Public: kick off a sync on app startup. Subsequent calls within the refresh
|
||||
// window are no-ops.
|
||||
export function warmSentContacts(): void {
|
||||
if (!cachedIndex) {
|
||||
const stored = loadState();
|
||||
if (stored) cachedIndex = indexFromStored(stored);
|
||||
}
|
||||
ensureFresh();
|
||||
}
|
||||
|
||||
export function invalidateSentContacts(): void {
|
||||
cachedIndex = null;
|
||||
lastRefreshAt = 0;
|
||||
}
|
||||
|
||||
function score(entry: IndexEntry, nowMs: number): number {
|
||||
const days = Math.max(0, (nowMs - entry.lastSeenMs) / (1000 * 60 * 60 * 24));
|
||||
const recency = Math.pow(0.5, days / RECENCY_HALFLIFE_DAYS);
|
||||
return entry.count * (0.5 + 0.5 * recency);
|
||||
}
|
||||
|
||||
function matchTier(q: string, entry: IndexEntry): number {
|
||||
if (!q) return 3;
|
||||
const name = entry.name.toLowerCase();
|
||||
const email = entry.email;
|
||||
if (name && name.startsWith(q)) return 0;
|
||||
if (email.startsWith(q)) return 1;
|
||||
if (name && name.includes(' ' + q)) return 1;
|
||||
if (name && name.includes(q)) return 2;
|
||||
if (email.includes(q)) return 3;
|
||||
return -1;
|
||||
}
|
||||
|
||||
export interface SearchOpts {
|
||||
limit?: number;
|
||||
excludeEmails?: string[];
|
||||
}
|
||||
|
||||
// Public: typeahead search over sent-recipient history. Returns instantly from
|
||||
// the in-memory cache (or disk on first call) and triggers a background refresh.
|
||||
export async function searchSentContacts(query: string, opts: SearchOpts = {}): Promise<Contact[]> {
|
||||
if (!cachedIndex) {
|
||||
const stored = loadState();
|
||||
if (stored) cachedIndex = indexFromStored(stored);
|
||||
}
|
||||
// Kick off (or join) a background refresh; never block the user.
|
||||
ensureFresh();
|
||||
|
||||
if (!cachedIndex) {
|
||||
// First-ever launch: wait for the initial sync so we can return something
|
||||
// useful instead of an empty list.
|
||||
if (pendingSync) {
|
||||
try { await pendingSync; } catch { /* return whatever we have */ }
|
||||
}
|
||||
if (!cachedIndex) return [];
|
||||
}
|
||||
|
||||
const q = query.trim().toLowerCase();
|
||||
const limit = Math.max(1, Math.min(50, opts.limit ?? 8));
|
||||
const excluded = new Set((opts.excludeEmails ?? []).map((e) => e.trim().toLowerCase()));
|
||||
const nowMs = Date.now();
|
||||
|
||||
const matches: Array<{ entry: IndexEntry; tier: number; s: number }> = [];
|
||||
for (const entry of cachedIndex.values()) {
|
||||
if (excluded.has(entry.email)) continue;
|
||||
const tier = matchTier(q, entry);
|
||||
if (tier < 0) continue;
|
||||
matches.push({ entry, tier, s: score(entry, nowMs) });
|
||||
}
|
||||
matches.sort((a, b) => (a.tier - b.tier) || (b.s - a.s));
|
||||
return matches.slice(0, limit).map(({ entry }) => ({
|
||||
name: entry.name,
|
||||
email: entry.email,
|
||||
count: entry.count,
|
||||
lastSeenMs: entry.lastSeenMs,
|
||||
}));
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ import { serviceLogger, type ServiceRunContext } from '../services/service_logge
|
|||
import { limitEventItems } from './limit_event_items.js';
|
||||
import { createEvent } from '../events/producer.js';
|
||||
import { classifyThread, getUserEmail } from './classify_thread.js';
|
||||
import { notifyIfEnabled } from '../application/notification/notifier.js';
|
||||
|
||||
// Configuration
|
||||
const SYNC_DIR = path.join(WorkDir, 'gmail_sync');
|
||||
|
|
@ -220,6 +221,26 @@ function summarizeGmailSync(threads: SyncedThread[]): string {
|
|||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire one OS notification per genuinely-new email thread. Only ever called
|
||||
* from the partial-sync (incremental) path, so the first-time connect — which
|
||||
* goes through fullSync — never notifies. Suppressed while the app is focused.
|
||||
*/
|
||||
function notifyNewEmails(threads: SyncedThread[]): void {
|
||||
for (const { threadId } of threads) {
|
||||
const snapshot = readCachedSnapshot(threadId)?.snapshot;
|
||||
const subject = snapshot?.subject?.trim() || '(no subject)';
|
||||
const from = snapshot?.from?.trim();
|
||||
void notifyIfEnabled('new_email', {
|
||||
title: from ? `New email from ${from}` : 'New email',
|
||||
message: subject,
|
||||
link: 'rowboat://open?type=chat',
|
||||
actionLabel: 'Open',
|
||||
onlyWhenBackground: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function publishGmailSyncEvent(threads: SyncedThread[]): Promise<void> {
|
||||
if (threads.length === 0) return;
|
||||
try {
|
||||
|
|
@ -1260,6 +1281,9 @@ async function partialSync(auth: OAuth2Client, startHistoryId: string, syncDir:
|
|||
const result = await processThread(auth, tid, syncDir, attachmentsDir);
|
||||
if (result) synced.push(result);
|
||||
}
|
||||
// Notify for the history-derived new threads only — before the older
|
||||
// backfilled threads are merged in below, so backfill stays silent.
|
||||
notifyNewEmails(synced);
|
||||
const backfilled = await backfillMissingRecentThreads(auth, syncDir, attachmentsDir, stateFile, lookbackDays);
|
||||
synced.push(...backfilled);
|
||||
|
||||
|
|
|
|||
|
|
@ -307,6 +307,7 @@ export class FSRunsRepo implements IRunsRepo {
|
|||
title: metadata.title,
|
||||
createdAt: metadata.start.ts!,
|
||||
agentId: metadata.start.agentName,
|
||||
...(metadata.start.useCase ? { useCase: metadata.start.useCase } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import container from "../di/container.js";
|
|||
import { IMessageQueue, UserMessageContentType, VoiceOutputMode, MiddlePaneContext } from "../application/lib/message-queue.js";
|
||||
import { AskHumanResponseEvent, ToolPermissionRequestEvent, ToolPermissionResponseEvent, CreateRunOptions, Run, ListRunsResponse, ToolPermissionAuthorizePayload, AskHumanResponsePayload } from "@x/shared/dist/runs.js";
|
||||
import { IRunsRepo } from "./repo.js";
|
||||
import { ICodeSessionsRepo } from "../code-mode/sessions/repo.js";
|
||||
import { IAgentRuntime } from "../agents/runtime.js";
|
||||
import { IBus } from "../application/lib/bus.js";
|
||||
import { IAbortRegistry } from "./abort-registry.js";
|
||||
|
|
@ -40,9 +41,23 @@ export async function createRun(opts: z.infer<typeof CreateRunOptions>): Promise
|
|||
return run;
|
||||
}
|
||||
|
||||
export async function createMessage(runId: string, message: UserMessageContentType, voiceInput?: boolean, voiceOutput?: VoiceOutputMode, searchEnabled?: boolean, middlePaneContext?: MiddlePaneContext, codeMode?: 'claude' | 'codex'): Promise<string> {
|
||||
export async function createMessage(runId: string, message: UserMessageContentType, voiceInput?: boolean, voiceOutput?: VoiceOutputMode, searchEnabled?: boolean, middlePaneContext?: MiddlePaneContext, codeMode?: 'claude' | 'codex', codeCwd?: string, codePolicy?: 'ask' | 'auto-approve-reads' | 'yolo'): Promise<string> {
|
||||
// Code-section sessions carry their coding context in the session meta.
|
||||
// Pin it here — not in the composer — so EVERY path into the run (assistant
|
||||
// chat pane, voice, palette) drives the session's agent in its directory,
|
||||
// and the session header stays the single source of truth.
|
||||
try {
|
||||
const sessionMeta = await container.resolve<ICodeSessionsRepo>('codeSessionsRepo').get(runId);
|
||||
if (sessionMeta) {
|
||||
codeMode = sessionMeta.agent;
|
||||
codeCwd = sessionMeta.cwd;
|
||||
codePolicy = sessionMeta.policy;
|
||||
}
|
||||
} catch {
|
||||
// sessions repo unavailable — treat as a regular chat run
|
||||
}
|
||||
const queue = container.resolve<IMessageQueue>('messageQueue');
|
||||
const id = await queue.enqueue(runId, message, voiceInput, voiceOutput, searchEnabled, middlePaneContext, codeMode);
|
||||
const id = await queue.enqueue(runId, message, voiceInput, voiceOutput, searchEnabled, middlePaneContext, codeMode, codeCwd, codePolicy);
|
||||
const runtime = container.resolve<IAgentRuntime>('agentRuntime');
|
||||
runtime.trigger(runId);
|
||||
return id;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import chokidar, { type FSWatcher } from 'chokidar';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { ensureWorkspaceRoot, absToRelPosix } from './workspace.js';
|
||||
import { WorkDir } from '../config/config.js';
|
||||
import { WorkspaceChangeEvent } from 'packages/shared/dist/workspace.js';
|
||||
|
|
@ -21,8 +22,15 @@ export async function createWorkspaceWatcher(
|
|||
): Promise<FSWatcher> {
|
||||
await ensureWorkspaceRoot();
|
||||
|
||||
// Code-section session worktrees are full repo checkouts (thousands of files,
|
||||
// possibly node_modules) living under WorkDir — watching them would flood the
|
||||
// event stream and burn file handles, and nothing in the app renders them
|
||||
// from workspace events.
|
||||
const codeModeDir = path.join(WorkDir, 'code-mode');
|
||||
const watcher = chokidar.watch(WorkDir, {
|
||||
ignoreInitial: true,
|
||||
ignored: (watchedPath: string) =>
|
||||
watchedPath === codeModeDir || watchedPath.startsWith(codeModeDir + path.sep),
|
||||
awaitWriteFinish: {
|
||||
stabilityThreshold: 150,
|
||||
pollInterval: 50,
|
||||
|
|
|
|||
71
apps/x/packages/shared/src/code-sessions.ts
Normal file
71
apps/x/packages/shared/src/code-sessions.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import z from "zod";
|
||||
import { CodingAgent, ApprovalPolicy } from "./code-mode.js";
|
||||
|
||||
// Shared zod schemas for the Code section: registered projects and coding
|
||||
// sessions. A coding session is backed by a run (session id == run id); the
|
||||
// mutable metadata below lives in its own per-session file.
|
||||
|
||||
export const CodeProject = z.object({
|
||||
id: z.string(),
|
||||
path: z.string(),
|
||||
name: z.string(),
|
||||
addedAt: z.iso.datetime(),
|
||||
});
|
||||
export type CodeProject = z.infer<typeof CodeProject>;
|
||||
|
||||
// Git facts about a project path, used to gate worktree creation in the UI.
|
||||
export const GitRepoInfo = z.object({
|
||||
isGitRepo: z.boolean(),
|
||||
branch: z.string().nullable(),
|
||||
hasCommits: z.boolean(),
|
||||
dirtyCount: z.number(),
|
||||
});
|
||||
export type GitRepoInfo = z.infer<typeof GitRepoInfo>;
|
||||
|
||||
// 'direct': the user's messages go straight to the ACP coding agent.
|
||||
// 'rowboat': Rowboat's copilot LLM orchestrates the agent via code_agent_run.
|
||||
export const CodeSessionMode = z.enum(["direct", "rowboat"]);
|
||||
export type CodeSessionMode = z.infer<typeof CodeSessionMode>;
|
||||
|
||||
// Derived live in the main process from the run event stream; not persisted.
|
||||
export const CodeSessionStatus = z.enum(["working", "needs-you", "idle"]);
|
||||
export type CodeSessionStatus = z.infer<typeof CodeSessionStatus>;
|
||||
|
||||
export const CodeWorktree = z.object({
|
||||
path: z.string(),
|
||||
branch: z.string(),
|
||||
// Branch the original checkout was on when the worktree was created;
|
||||
// merge-back targets whatever the checkout is on at merge time, this is
|
||||
// informational.
|
||||
baseBranch: z.string().nullable(),
|
||||
mergedAt: z.iso.datetime().optional(),
|
||||
removedAt: z.iso.datetime().optional(),
|
||||
});
|
||||
export type CodeWorktree = z.infer<typeof CodeWorktree>;
|
||||
|
||||
export const CodeSession = z.object({
|
||||
id: z.string(), // == runId
|
||||
projectId: z.string(),
|
||||
title: z.string(),
|
||||
agent: CodingAgent,
|
||||
mode: CodeSessionMode,
|
||||
policy: ApprovalPolicy,
|
||||
// Where the agent works: the project path, or the worktree path.
|
||||
cwd: z.string(),
|
||||
worktree: CodeWorktree.optional(),
|
||||
createdAt: z.iso.datetime(),
|
||||
lastActivityAt: z.iso.datetime().optional(),
|
||||
});
|
||||
export type CodeSession = z.infer<typeof CodeSession>;
|
||||
|
||||
export const GitFileState = z.enum(["modified", "added", "deleted", "untracked", "renamed"]);
|
||||
export type GitFileState = z.infer<typeof GitFileState>;
|
||||
|
||||
export const GitStatusFile = z.object({
|
||||
path: z.string(),
|
||||
state: GitFileState,
|
||||
// Null when git can't compute line counts (binary files).
|
||||
insertions: z.number().nullable(),
|
||||
deletions: z.number().nullable(),
|
||||
});
|
||||
export type GitStatusFile = z.infer<typeof GitStatusFile>;
|
||||
|
|
@ -17,4 +17,6 @@ export * as frontmatter from './frontmatter.js';
|
|||
export * as bases from './bases.js';
|
||||
export * as browserControl from './browser-control.js';
|
||||
export * as billing from './billing.js';
|
||||
export * as notificationSettings from './notification-settings.js';
|
||||
export * as codeSessions from './code-sessions.js';
|
||||
export { PrefixLogger };
|
||||
|
|
|
|||
|
|
@ -19,7 +19,9 @@ import { ZListToolkitsResponse } from './composio.js';
|
|||
import { BrowserStateSchema } from './browser-control.js';
|
||||
import { BillingInfoSchema } from './billing.js';
|
||||
import { EmailBlockSchema, GmailThreadSchema } from './blocks.js';
|
||||
import { PermissionDecision, ApprovalPolicy } from './code-mode.js';
|
||||
import { PermissionDecision, ApprovalPolicy, CodingAgent } from './code-mode.js';
|
||||
import { NotificationSettingsSchema } from './notification-settings.js';
|
||||
import { CodeProject, CodeSession, CodeSessionMode, CodeSessionStatus, GitRepoInfo, GitStatusFile } from './code-sessions.js';
|
||||
|
||||
// ============================================================================
|
||||
// Runtime Validation Schemas (Single Source of Truth)
|
||||
|
|
@ -202,6 +204,21 @@ const ipcSchemas = {
|
|||
}),
|
||||
res: z.object({}),
|
||||
},
|
||||
'gmail:searchContacts': {
|
||||
req: z.object({
|
||||
query: z.string(),
|
||||
limit: z.number().int().positive().optional(),
|
||||
excludeEmails: z.array(z.string()).optional(),
|
||||
}),
|
||||
res: z.object({
|
||||
contacts: z.array(z.object({
|
||||
name: z.string(),
|
||||
email: z.string(),
|
||||
count: z.number(),
|
||||
lastSeenMs: z.number(),
|
||||
})),
|
||||
}),
|
||||
},
|
||||
'mcp:listTools': {
|
||||
req: z.object({
|
||||
serverName: z.string(),
|
||||
|
|
@ -231,6 +248,10 @@ const ipcSchemas = {
|
|||
voiceOutput: z.enum(['summary', 'full']).optional(),
|
||||
searchEnabled: z.boolean().optional(),
|
||||
codeMode: z.enum(['claude', 'codex']).optional(),
|
||||
// Code-section sessions pin the coding agent's working directory and
|
||||
// approval policy for the whole turn (see code_agent_run overrides).
|
||||
codeCwd: z.string().optional(),
|
||||
codePolicy: ApprovalPolicy.optional(),
|
||||
middlePaneContext: z.discriminatedUnion('kind', [
|
||||
z.object({
|
||||
kind: z.literal('note'),
|
||||
|
|
@ -460,6 +481,218 @@ const ipcSchemas = {
|
|||
codex: z.object({ installed: z.boolean(), signedIn: z.boolean() }),
|
||||
}),
|
||||
},
|
||||
// ==========================================================================
|
||||
// Code section: project registry + coding sessions
|
||||
// ==========================================================================
|
||||
'codeProject:add': {
|
||||
req: z.object({
|
||||
path: z.string(),
|
||||
}),
|
||||
res: z.object({
|
||||
project: CodeProject,
|
||||
git: GitRepoInfo,
|
||||
}),
|
||||
},
|
||||
'codeProject:remove': {
|
||||
req: z.object({
|
||||
projectId: z.string(),
|
||||
}),
|
||||
res: z.object({
|
||||
success: z.literal(true),
|
||||
}),
|
||||
},
|
||||
'codeProject:list': {
|
||||
req: z.null(),
|
||||
res: z.object({
|
||||
projects: z.array(z.object({
|
||||
project: CodeProject,
|
||||
git: GitRepoInfo,
|
||||
})),
|
||||
}),
|
||||
},
|
||||
'codeSession:create': {
|
||||
req: z.object({
|
||||
projectId: z.string(),
|
||||
title: z.string().optional(),
|
||||
agent: CodingAgent,
|
||||
mode: CodeSessionMode,
|
||||
policy: ApprovalPolicy,
|
||||
isolation: z.enum(['in-repo', 'worktree']),
|
||||
// LLM for Rowboat-mode turns. Unset = the configured default. Like any
|
||||
// chat, the model is fixed once the session's run exists.
|
||||
model: z.string().optional(),
|
||||
provider: z.string().optional(),
|
||||
}),
|
||||
res: z.object({
|
||||
session: CodeSession,
|
||||
}),
|
||||
},
|
||||
'codeSession:list': {
|
||||
req: z.null(),
|
||||
res: z.object({
|
||||
sessions: z.array(CodeSession),
|
||||
statuses: z.record(z.string(), CodeSessionStatus),
|
||||
}),
|
||||
},
|
||||
'codeSession:update': {
|
||||
req: z.object({
|
||||
sessionId: z.string(),
|
||||
patch: CodeSession.pick({ title: true, mode: true, policy: true, agent: true }).partial(),
|
||||
}),
|
||||
res: z.object({
|
||||
session: CodeSession,
|
||||
}),
|
||||
},
|
||||
'codeSession:delete': {
|
||||
req: z.object({
|
||||
sessionId: z.string(),
|
||||
removeWorktree: z.boolean().optional(),
|
||||
deleteBranch: z.boolean().optional(),
|
||||
}),
|
||||
res: z.object({
|
||||
success: z.literal(true),
|
||||
}),
|
||||
},
|
||||
// Direct-drive: send the user's message straight to the session's ACP agent
|
||||
// (no copilot LLM in between). Streams back over `runs:events`.
|
||||
'codeSession:sendMessage': {
|
||||
req: z.object({
|
||||
sessionId: z.string(),
|
||||
text: z.string().min(1),
|
||||
}),
|
||||
res: z.object({
|
||||
accepted: z.boolean(),
|
||||
error: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
'codeSession:stop': {
|
||||
req: z.object({
|
||||
sessionId: z.string(),
|
||||
}),
|
||||
res: z.object({
|
||||
success: z.literal(true),
|
||||
}),
|
||||
},
|
||||
'codeSession:gitStatus': {
|
||||
req: z.object({
|
||||
sessionId: z.string(),
|
||||
}),
|
||||
res: z.object({
|
||||
isRepo: z.boolean(),
|
||||
branch: z.string().nullable(),
|
||||
hasCommits: z.boolean(),
|
||||
files: z.array(GitStatusFile),
|
||||
}),
|
||||
},
|
||||
'codeSession:fileDiff': {
|
||||
req: z.object({
|
||||
sessionId: z.string(),
|
||||
path: z.string(),
|
||||
}),
|
||||
res: z.object({
|
||||
oldText: z.string(),
|
||||
newText: z.string(),
|
||||
isBinary: z.boolean(),
|
||||
tooLarge: z.boolean(),
|
||||
}),
|
||||
},
|
||||
'codeSession:readdir': {
|
||||
req: z.object({
|
||||
sessionId: z.string(),
|
||||
relPath: z.string(),
|
||||
}),
|
||||
res: z.object({
|
||||
entries: z.array(z.object({
|
||||
name: z.string(),
|
||||
kind: z.enum(['file', 'dir']),
|
||||
size: z.number().optional(),
|
||||
})),
|
||||
}),
|
||||
},
|
||||
'codeSession:readFile': {
|
||||
req: z.object({
|
||||
sessionId: z.string(),
|
||||
relPath: z.string(),
|
||||
}),
|
||||
res: z.object({
|
||||
content: z.string(),
|
||||
isBinary: z.boolean(),
|
||||
tooLarge: z.boolean(),
|
||||
}),
|
||||
},
|
||||
'codeSession:mergeBack': {
|
||||
req: z.object({
|
||||
sessionId: z.string(),
|
||||
}),
|
||||
res: z.object({
|
||||
ok: z.boolean(),
|
||||
conflict: z.boolean().optional(),
|
||||
message: z.string(),
|
||||
}),
|
||||
},
|
||||
'codeSession:cleanupWorktree': {
|
||||
req: z.object({
|
||||
sessionId: z.string(),
|
||||
deleteBranch: z.boolean(),
|
||||
}),
|
||||
res: z.object({
|
||||
success: z.boolean(),
|
||||
error: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
// main → renderer: live session status transitions from the status tracker.
|
||||
'codeSession:status': {
|
||||
req: z.object({
|
||||
sessionId: z.string(),
|
||||
status: CodeSessionStatus,
|
||||
}),
|
||||
res: z.null(),
|
||||
},
|
||||
// ==========================================================================
|
||||
// Embedded terminal (Code section): one PTY per coding session
|
||||
// ==========================================================================
|
||||
// Create-or-attach. Returns the scrollback backlog so a remounted view can
|
||||
// repaint what happened while it was closed.
|
||||
'terminal:ensure': {
|
||||
req: z.object({
|
||||
id: z.string(),
|
||||
cwd: z.string(),
|
||||
cols: z.number().int().positive(),
|
||||
rows: z.number().int().positive(),
|
||||
}),
|
||||
res: z.object({
|
||||
backlog: z.string(),
|
||||
running: z.boolean(),
|
||||
}),
|
||||
},
|
||||
'terminal:input': {
|
||||
req: z.object({
|
||||
id: z.string(),
|
||||
data: z.string(),
|
||||
}),
|
||||
res: z.object({ success: z.literal(true) }),
|
||||
},
|
||||
'terminal:resize': {
|
||||
req: z.object({
|
||||
id: z.string(),
|
||||
cols: z.number().int().positive(),
|
||||
rows: z.number().int().positive(),
|
||||
}),
|
||||
res: z.object({ success: z.literal(true) }),
|
||||
},
|
||||
'terminal:dispose': {
|
||||
req: z.object({ id: z.string() }),
|
||||
res: z.object({ success: z.literal(true) }),
|
||||
},
|
||||
// main → renderer streams
|
||||
'terminal:data': {
|
||||
req: z.object({ id: z.string(), data: z.string() }),
|
||||
res: z.null(),
|
||||
},
|
||||
'terminal:exit': {
|
||||
req: z.object({ id: z.string(), exitCode: z.number() }),
|
||||
res: z.null(),
|
||||
},
|
||||
'granola:setConfig': {
|
||||
req: z.object({
|
||||
enabled: z.boolean(),
|
||||
|
|
@ -630,6 +863,15 @@ const ipcSchemas = {
|
|||
path: z.string().nullable(),
|
||||
}),
|
||||
},
|
||||
'dialog:openFiles': {
|
||||
req: z.object({
|
||||
defaultPath: z.string().optional(),
|
||||
title: z.string().optional(),
|
||||
}),
|
||||
res: z.object({
|
||||
paths: z.array(z.string()),
|
||||
}),
|
||||
},
|
||||
// Knowledge version history channels
|
||||
'knowledge:history': {
|
||||
req: z.object({ path: RelPath }),
|
||||
|
|
@ -687,6 +929,16 @@ const ipcSchemas = {
|
|||
mimeType: z.string(),
|
||||
}),
|
||||
},
|
||||
// Ensures the OS-level microphone permission is settled before capturing.
|
||||
// On first-ever use (macOS) the permission is 'not-determined'; resolving
|
||||
// the native prompt up front prevents the in-flight getUserMedia from
|
||||
// rejecting on the first mic click.
|
||||
'voice:ensureMicAccess': {
|
||||
req: z.null(),
|
||||
res: z.object({
|
||||
granted: z.boolean(),
|
||||
}),
|
||||
},
|
||||
'meeting:checkScreenPermission': {
|
||||
req: z.null(),
|
||||
res: z.object({
|
||||
|
|
@ -1003,6 +1255,17 @@ const ipcSchemas = {
|
|||
req: z.null(),
|
||||
res: BillingInfoSchema,
|
||||
},
|
||||
// Notification settings channels
|
||||
'notifications:getSettings': {
|
||||
req: z.null(),
|
||||
res: NotificationSettingsSchema,
|
||||
},
|
||||
'notifications:setSettings': {
|
||||
req: NotificationSettingsSchema,
|
||||
res: z.object({
|
||||
success: z.literal(true),
|
||||
}),
|
||||
},
|
||||
} as const;
|
||||
|
||||
// ============================================================================
|
||||
|
|
|
|||
36
apps/x/packages/shared/src/notification-settings.ts
Normal file
36
apps/x/packages/shared/src/notification-settings.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Notification categories the user can independently toggle.
|
||||
*
|
||||
* - chat_completion: an agent finished generating a response
|
||||
* - new_email: a new email arrived during incremental Gmail sync
|
||||
* - agent_permission: an agent is requesting permission to run a tool
|
||||
*/
|
||||
export const NotificationCategorySchema = z.enum([
|
||||
'chat_completion',
|
||||
'new_email',
|
||||
'agent_permission',
|
||||
]);
|
||||
|
||||
export const NotificationCategoriesSchema = z.object({
|
||||
chat_completion: z.boolean(),
|
||||
new_email: z.boolean(),
|
||||
agent_permission: z.boolean(),
|
||||
});
|
||||
|
||||
export const NotificationSettingsSchema = z.object({
|
||||
categories: NotificationCategoriesSchema,
|
||||
});
|
||||
|
||||
export const DEFAULT_NOTIFICATION_SETTINGS: NotificationSettings = {
|
||||
categories: {
|
||||
chat_completion: true,
|
||||
new_email: true,
|
||||
agent_permission: true,
|
||||
},
|
||||
};
|
||||
|
||||
export type NotificationCategory = z.infer<typeof NotificationCategorySchema>;
|
||||
export type NotificationCategories = z.infer<typeof NotificationCategoriesSchema>;
|
||||
export type NotificationSettings = z.infer<typeof NotificationSettingsSchema>;
|
||||
|
|
@ -31,6 +31,7 @@ export const StartEvent = BaseRunEvent.extend({
|
|||
"background_task_agent",
|
||||
"meeting_note",
|
||||
"knowledge_sync",
|
||||
"code_session",
|
||||
]).optional(),
|
||||
subUseCase: z.string().optional(),
|
||||
});
|
||||
|
|
@ -188,6 +189,7 @@ export const UseCase = z.enum([
|
|||
"background_task_agent",
|
||||
"meeting_note",
|
||||
"knowledge_sync",
|
||||
"code_session",
|
||||
]);
|
||||
|
||||
export const Run = z.object({
|
||||
|
|
@ -209,6 +211,7 @@ export const ListRunsResponse = z.object({
|
|||
title: true,
|
||||
createdAt: true,
|
||||
agentId: true,
|
||||
useCase: true,
|
||||
})),
|
||||
nextCursor: z.string().optional(),
|
||||
});
|
||||
|
|
|
|||
647
apps/x/pnpm-lock.yaml
generated
647
apps/x/pnpm-lock.yaml
generated
|
|
@ -76,6 +76,9 @@ importers:
|
|||
mammoth:
|
||||
specifier: ^1.11.0
|
||||
version: 1.11.0
|
||||
node-pty:
|
||||
specifier: ^1.1.0
|
||||
version: 1.1.0
|
||||
papaparse:
|
||||
specifier: ^5.5.3
|
||||
version: 5.5.3
|
||||
|
|
@ -95,6 +98,9 @@ importers:
|
|||
'@electron-forge/cli':
|
||||
specifier: ^7.10.2
|
||||
version: 7.11.1(encoding@0.1.13)(esbuild@0.24.2)
|
||||
'@electron-forge/maker-base':
|
||||
specifier: ^7.11.1
|
||||
version: 7.11.1
|
||||
'@electron-forge/maker-deb':
|
||||
specifier: ^7.11.1
|
||||
version: 7.11.1
|
||||
|
|
@ -144,9 +150,27 @@ importers:
|
|||
|
||||
apps/renderer:
|
||||
dependencies:
|
||||
'@codemirror/language':
|
||||
specifier: ^6.12.3
|
||||
version: 6.12.3
|
||||
'@codemirror/language-data':
|
||||
specifier: ^6.5.2
|
||||
version: 6.5.2
|
||||
'@codemirror/merge':
|
||||
specifier: ^6.12.2
|
||||
version: 6.12.2
|
||||
'@codemirror/state':
|
||||
specifier: ^6.6.0
|
||||
version: 6.6.0
|
||||
'@codemirror/view':
|
||||
specifier: ^6.43.1
|
||||
version: 6.43.1
|
||||
'@eigenpal/docx-editor-react':
|
||||
specifier: ^1.0.3
|
||||
version: 1.0.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(ai@5.0.117(zod@4.2.1))(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)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@lezer/highlight':
|
||||
specifier: ^1.2.3
|
||||
version: 1.2.3
|
||||
'@radix-ui/react-avatar':
|
||||
specifier: ^1.1.11
|
||||
version: 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
|
|
@ -206,16 +230,16 @@ importers:
|
|||
version: 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)
|
||||
'@tiptap/extension-placeholder':
|
||||
specifier: 3.22.4
|
||||
version: 3.22.4(@tiptap/extensions@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))
|
||||
version: 3.22.4(@tiptap/extensions@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))
|
||||
'@tiptap/extension-table':
|
||||
specifier: 3.22.4
|
||||
version: 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)
|
||||
'@tiptap/extension-task-item':
|
||||
specifier: 3.22.4
|
||||
version: 3.22.4(@tiptap/extension-list@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))
|
||||
version: 3.22.4(@tiptap/extension-list@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))
|
||||
'@tiptap/extension-task-list':
|
||||
specifier: 3.22.4
|
||||
version: 3.22.4(@tiptap/extension-list@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))
|
||||
version: 3.22.4(@tiptap/extension-list@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))
|
||||
'@tiptap/pm':
|
||||
specifier: 3.22.4
|
||||
version: 3.22.4
|
||||
|
|
@ -231,6 +255,12 @@ importers:
|
|||
'@x/shared':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/shared
|
||||
'@xterm/addon-fit':
|
||||
specifier: ^0.11.0
|
||||
version: 0.11.0
|
||||
'@xterm/xterm':
|
||||
specifier: ^6.0.0
|
||||
version: 6.0.0
|
||||
ai:
|
||||
specifier: ^5.0.117
|
||||
version: 5.0.117(zod@4.2.1)
|
||||
|
|
@ -243,6 +273,9 @@ importers:
|
|||
cmdk:
|
||||
specifier: ^1.1.1
|
||||
version: 1.1.1(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
codemirror:
|
||||
specifier: ^6.0.2
|
||||
version: 6.0.2
|
||||
lucide-react:
|
||||
specifier: ^0.562.0
|
||||
version: 0.562.0(react@19.2.3)
|
||||
|
|
@ -596,25 +629,21 @@ packages:
|
|||
resolution: {integrity: sha512-R7KEVjxkR4rYgIQoHGBzwPdUJYxRTO8I4vHjRbMLH1eW4FS7BJvVs7ogfKR/NnHFBvMVqtC+l6jHLQv8bobUiw==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.156':
|
||||
resolution: {integrity: sha512-H0Nfd41iw5isto9uQI1FlVSZ0eaDttr8rBpJMR25oK/mj3egMO5EmZ6aAxeeUYSLn2mSU50HA5VNxlGUE118TQ==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.156':
|
||||
resolution: {integrity: sha512-/Q6WUizI6a+hqZZ6ElwRU0PEuFhOoN4v6CuU35HHbiZ/7uaocGht4A8ZIgK1Fw6wOGtZzGLbc00CA1OU1Zg8EA==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@anthropic-ai/claude-agent-sdk-linux-x64@0.3.156':
|
||||
resolution: {integrity: sha512-ymhrdlbWoYvTACUdaGdhrEv+ZMfwXLsf0BRLkr/IvY5aqybP7URzWmmZGOtDQpqkT/8xu/UCGqUYH3woJwUxfg==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.156':
|
||||
resolution: {integrity: sha512-5sAeNObQQrMy4NF9HwxewrMnU7mVxZDHh+/MfJVQSz0GSTvXQ6gOuRH8helMlfspoU6VOdekPxVLRooX/3foEw==}
|
||||
|
|
@ -917,6 +946,99 @@ packages:
|
|||
'@chevrotain/utils@12.0.0':
|
||||
resolution: {integrity: sha512-lB59uJoaGIfOOL9knQqQRfhl9g7x8/wqFkp13zTdkRu1huG9kg6IJs1O8hqj9rs6h7orGxHJUKb+mX3rPbWGhA==}
|
||||
|
||||
'@codemirror/autocomplete@6.20.3':
|
||||
resolution: {integrity: sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==}
|
||||
|
||||
'@codemirror/commands@6.10.3':
|
||||
resolution: {integrity: sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q==}
|
||||
|
||||
'@codemirror/lang-angular@0.1.4':
|
||||
resolution: {integrity: sha512-oap+gsltb/fzdlTQWD6BFF4bSLKcDnlxDsLdePiJpCVNKWXSTAbiiQeYI3UmES+BLAdkmIC1WjyztC1pi/bX4g==}
|
||||
|
||||
'@codemirror/lang-cpp@6.0.3':
|
||||
resolution: {integrity: sha512-URM26M3vunFFn9/sm6rzqrBzDgfWuDixp85uTY49wKudToc2jTHUrKIGGKs+QWND+YLofNNZpxcNGRynFJfvgA==}
|
||||
|
||||
'@codemirror/lang-css@6.3.1':
|
||||
resolution: {integrity: sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==}
|
||||
|
||||
'@codemirror/lang-go@6.0.1':
|
||||
resolution: {integrity: sha512-7fNvbyNylvqCphW9HD6WFnRpcDjr+KXX/FgqXy5H5ZS0eC5edDljukm/yNgYkwTsgp2busdod50AOTIy6Jikfg==}
|
||||
|
||||
'@codemirror/lang-html@6.4.11':
|
||||
resolution: {integrity: sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==}
|
||||
|
||||
'@codemirror/lang-java@6.0.2':
|
||||
resolution: {integrity: sha512-m5Nt1mQ/cznJY7tMfQTJchmrjdjQ71IDs+55d1GAa8DGaB8JXWsVCkVT284C3RTASaY43YknrK2X3hPO/J3MOQ==}
|
||||
|
||||
'@codemirror/lang-javascript@6.2.5':
|
||||
resolution: {integrity: sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==}
|
||||
|
||||
'@codemirror/lang-jinja@6.0.1':
|
||||
resolution: {integrity: sha512-P5kyHLObzjtbGj16h+hyvZTxJhSjBEeSx4wMjbnAf3b0uwTy2+F0zGjMZL4PQOm/mh2eGZ5xUDVZXgwP783Nsw==}
|
||||
|
||||
'@codemirror/lang-json@6.0.2':
|
||||
resolution: {integrity: sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==}
|
||||
|
||||
'@codemirror/lang-less@6.0.2':
|
||||
resolution: {integrity: sha512-EYdQTG22V+KUUk8Qq582g7FMnCZeEHsyuOJisHRft/mQ+ZSZ2w51NupvDUHiqtsOy7It5cHLPGfHQLpMh9bqpQ==}
|
||||
|
||||
'@codemirror/lang-liquid@6.3.2':
|
||||
resolution: {integrity: sha512-6PDVU3ZnfeYyz1at1E/ttorErZvZFXXt1OPhtfe1EZJ2V2iDFa0CwPqPgG5F7NXN0yONGoBogKmFAafKTqlwIw==}
|
||||
|
||||
'@codemirror/lang-markdown@6.5.0':
|
||||
resolution: {integrity: sha512-0K40bZ35jpHya6FriukbgaleaqzBLZfOh7HuzqbMxBXkbYMJDxfF39c23xOgxFezR+3G+tR2/Mup+Xk865OMvw==}
|
||||
|
||||
'@codemirror/lang-php@6.0.2':
|
||||
resolution: {integrity: sha512-ZKy2v1n8Fc8oEXj0Th0PUMXzQJ0AIR6TaZU+PbDHExFwdu+guzOA4jmCHS1Nz4vbFezwD7LyBdDnddSJeScMCA==}
|
||||
|
||||
'@codemirror/lang-python@6.2.1':
|
||||
resolution: {integrity: sha512-IRjC8RUBhn9mGR9ywecNhB51yePWCGgvHfY1lWN/Mrp3cKuHr0isDKia+9HnvhiWNnMpbGhWrkhuWOc09exRyw==}
|
||||
|
||||
'@codemirror/lang-rust@6.0.2':
|
||||
resolution: {integrity: sha512-EZaGjCUegtiU7kSMvOfEZpaCReowEf3yNidYu7+vfuGTm9ow4mthAparY5hisJqOHmJowVH3Upu+eJlUji6qqA==}
|
||||
|
||||
'@codemirror/lang-sass@6.0.2':
|
||||
resolution: {integrity: sha512-l/bdzIABvnTo1nzdY6U+kPAC51czYQcOErfzQ9zSm9D8GmNPD0WTW8st/CJwBTPLO8jlrbyvlSEcN20dc4iL0Q==}
|
||||
|
||||
'@codemirror/lang-sql@6.10.0':
|
||||
resolution: {integrity: sha512-6ayPkEd/yRw0XKBx5uAiToSgGECo/GY2NoJIHXIIQh1EVwLuKoU8BP/qK0qH5NLXAbtJRLuT73hx7P9X34iO4w==}
|
||||
|
||||
'@codemirror/lang-vue@0.1.3':
|
||||
resolution: {integrity: sha512-QSKdtYTDRhEHCfo5zOShzxCmqKJvgGrZwDQSdbvCRJ5pRLWBS7pD/8e/tH44aVQT6FKm0t6RVNoSUWHOI5vNug==}
|
||||
|
||||
'@codemirror/lang-wast@6.0.2':
|
||||
resolution: {integrity: sha512-Imi2KTpVGm7TKuUkqyJ5NRmeFWF7aMpNiwHnLQe0x9kmrxElndyH0K6H/gXtWwY6UshMRAhpENsgfpSwsgmC6Q==}
|
||||
|
||||
'@codemirror/lang-xml@6.1.0':
|
||||
resolution: {integrity: sha512-3z0blhicHLfwi2UgkZYRPioSgVTo9PV5GP5ducFH6FaHy0IAJRg+ixj5gTR1gnT/glAIC8xv4w2VL1LoZfs+Jg==}
|
||||
|
||||
'@codemirror/lang-yaml@6.1.3':
|
||||
resolution: {integrity: sha512-AZ8DJBuXGVHybpBQhmZtgew5//4hv3tdkXnr3vDmOUMJRuB6vn/uuwtmTOTlqEaQFg3hQSVeA90NmvIQyUV6FQ==}
|
||||
|
||||
'@codemirror/language-data@6.5.2':
|
||||
resolution: {integrity: sha512-CPkWBKrNS8stYbEU5kwBwTf3JB1kghlbh4FSAwzGW2TEscdeHHH4FGysREW86Mqnj3Qn09s0/6Ea/TutmoTobg==}
|
||||
|
||||
'@codemirror/language@6.12.3':
|
||||
resolution: {integrity: sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==}
|
||||
|
||||
'@codemirror/legacy-modes@6.5.3':
|
||||
resolution: {integrity: sha512-xCsmIzH78MyWkib9jlPaaun57XNkfbMIhagfaZVd0iLTqlpw3jXaIcbZm72MTmmn64eTZpBVNjbyYh+QXnxRsg==}
|
||||
|
||||
'@codemirror/lint@6.9.7':
|
||||
resolution: {integrity: sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==}
|
||||
|
||||
'@codemirror/merge@6.12.2':
|
||||
resolution: {integrity: sha512-V8JvyAPjHbPupqP7BeMcsdsYCbyPij74jxIbaIJDORI+VZzW44zFmon8bF+oxGWvOKhcRmkiUMXd8MxHr3YA2w==}
|
||||
|
||||
'@codemirror/search@6.7.0':
|
||||
resolution: {integrity: sha512-ZvGm99wc/s2cITtMT15LFdn8aH/aS+V+DqyGq/N5ZlV5vWtH+nILvC2nw0zX7ByNoHHDZ2IxxdW38O0tc5nVHg==}
|
||||
|
||||
'@codemirror/state@6.6.0':
|
||||
resolution: {integrity: sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==}
|
||||
|
||||
'@codemirror/view@6.43.1':
|
||||
resolution: {integrity: sha512-+BIjw/AG3tDQ4pJgTLPYdAW25eDE66YsvM4LKyVPgGzVgZ4a9Wj1SRX8kPVKgBDdPt8oHtZ15F0qx7p0oOHdHw==}
|
||||
|
||||
'@composio/client@0.1.0-alpha.56':
|
||||
resolution: {integrity: sha512-hNgChB5uhdvT4QXNzzfUuvtG6vrfanQQFY2hPyKwbeR4x6mEmIGFiZ4y2qynErdUWldAZiB/7pY/MBMg6Q9E0g==}
|
||||
|
||||
|
|
@ -1621,6 +1743,57 @@ packages:
|
|||
'@jridgewell/trace-mapping@0.3.31':
|
||||
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
||||
|
||||
'@lezer/common@1.5.2':
|
||||
resolution: {integrity: sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==}
|
||||
|
||||
'@lezer/cpp@1.1.6':
|
||||
resolution: {integrity: sha512-vh9gWWJOXFVY8HBHK3Twzq8MgwG2iN4GSyzBP9sCGTe37P15x2R14VaBQk0VA0ezTRN1KHYBBsHhvpGZ2Xy/pA==}
|
||||
|
||||
'@lezer/css@1.3.3':
|
||||
resolution: {integrity: sha512-RzBo8r+/6QJeow7aPHIpGVIH59xTcJXp399820gZoMo9noQDRVpJLheIBUicYwKcsbOYoBRoLZlf2720dG/4Tg==}
|
||||
|
||||
'@lezer/go@1.0.1':
|
||||
resolution: {integrity: sha512-xToRsYxwsgJNHTgNdStpcvmbVuKxTapV0dM0wey1geMMRc9aggoVyKgzYp41D2/vVOx+Ii4hmE206kvxIXBVXQ==}
|
||||
|
||||
'@lezer/highlight@1.2.3':
|
||||
resolution: {integrity: sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==}
|
||||
|
||||
'@lezer/html@1.3.13':
|
||||
resolution: {integrity: sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==}
|
||||
|
||||
'@lezer/java@1.1.3':
|
||||
resolution: {integrity: sha512-yHquUfujwg6Yu4Fd1GNHCvidIvJwi/1Xu2DaKl/pfWIA2c1oXkVvawH3NyXhCaFx4OdlYBVX5wvz2f7Aoa/4Xw==}
|
||||
|
||||
'@lezer/javascript@1.5.4':
|
||||
resolution: {integrity: sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==}
|
||||
|
||||
'@lezer/json@1.0.3':
|
||||
resolution: {integrity: sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==}
|
||||
|
||||
'@lezer/lr@1.4.10':
|
||||
resolution: {integrity: sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==}
|
||||
|
||||
'@lezer/markdown@1.6.4':
|
||||
resolution: {integrity: sha512-N0SxazMj4k65DBfaf1azqtMZd6u7MqluP84/NZnB/io8Td9aleFmAhz9hcbvSfsxT5tdYlJ5qgv5aMJGY4zEtA==}
|
||||
|
||||
'@lezer/php@1.0.5':
|
||||
resolution: {integrity: sha512-W7asp9DhM6q0W6DYNwIkLSKOvxlXRrif+UXBMxzsJUuqmhE7oVU+gS3THO4S/Puh7Xzgm858UNaFi6dxTP8dJA==}
|
||||
|
||||
'@lezer/python@1.1.19':
|
||||
resolution: {integrity: sha512-MhQIURHRytsNzP/YXnqpYKW6la6voAH3kyplTOOiCdjyFY6cWWGFVmYVdHIPrElqSDf4iCDktQCockB9FxuhzQ==}
|
||||
|
||||
'@lezer/rust@1.0.2':
|
||||
resolution: {integrity: sha512-Lz5sIPBdF2FUXcWeCu1//ojFAZqzTQNRga0aYv6dYXqJqPfMdCAI0NzajWUd4Xijj1IKJLtjoXRPMvTKWBcqKg==}
|
||||
|
||||
'@lezer/sass@1.1.0':
|
||||
resolution: {integrity: sha512-3mMGdCTUZ/84ArHOuXWQr37pnf7f+Nw9ycPUeKX+wu19b7pSMcZGLbaXwvD2APMBDOGxPmpK/O6S1v1EvLoqgQ==}
|
||||
|
||||
'@lezer/xml@1.0.6':
|
||||
resolution: {integrity: sha512-CdDwirL0OEaStFue/66ZmFSeppuL6Dwjlk8qk153mSQwiSH/Dlri4GNymrNWnUmPl2Um7QfV1FO9KFUyX3Twww==}
|
||||
|
||||
'@lezer/yaml@1.0.4':
|
||||
resolution: {integrity: sha512-2lrrHqxalACEbxIbsjhqGpSW8kWpUKuY6RHgnSAFZa6qK62wvnPxA8hGOwOoDbwHcOFs5M4o27mjGu+P7TvBmw==}
|
||||
|
||||
'@listr2/prompt-adapter-inquirer@2.0.22':
|
||||
resolution: {integrity: sha512-hV36ZoY+xKL6pYOt1nPNnkciFkn89KZwqLhAFzJvYysAvL5uBQdiADZx/8bIDXIukzzwG0QlPYolgMzQUtKgpQ==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
|
@ -1635,6 +1808,9 @@ packages:
|
|||
resolution: {integrity: sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg==}
|
||||
engines: {node: '>= 12.13.0'}
|
||||
|
||||
'@marijn/find-cluster-break@1.0.2':
|
||||
resolution: {integrity: sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==}
|
||||
|
||||
'@mermaid-js/parser@1.1.0':
|
||||
resolution: {integrity: sha512-gxK9ZX2+Fex5zu8LhRQoMeMPEHbc73UKZ0FQ54YrQtUxE1VVhMwzeNtKRPAu5aXks4FasbMe4xB4bWrmq6Jlxw==}
|
||||
|
||||
|
|
@ -1677,35 +1853,30 @@ packages:
|
|||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@napi-rs/canvas-linux-arm64-musl@0.1.80':
|
||||
resolution: {integrity: sha512-1XbCOz/ymhj24lFaIXtWnwv/6eFHXDrjP0jYkc6iHQ9q8oXKzUX1Lc6bu+wuGiLhGh2GS/2JlfORC5ZcXimRcg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@napi-rs/canvas-linux-riscv64-gnu@0.1.80':
|
||||
resolution: {integrity: sha512-XTzR125w5ZMs0lJcxRlS1K3P5RaZ9RmUsPtd1uGt+EfDyYMu4c6SEROYsxyatbbu/2+lPe7MPHOO/0a0x7L/gw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@napi-rs/canvas-linux-x64-gnu@0.1.80':
|
||||
resolution: {integrity: sha512-BeXAmhKg1kX3UCrJsYbdQd3hIMDH/K6HnP/pG2LuITaXhXBiNdh//TVVVVCBbJzVQaV5gK/4ZOCMrQW9mvuTqA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@napi-rs/canvas-linux-x64-musl@0.1.80':
|
||||
resolution: {integrity: sha512-x0XvZWdHbkgdgucJsRxprX/4o4sEed7qo9rCQA9ugiS9qE2QvP0RIiEugtZhfLH3cyI+jIRFJHV4Fuz+1BHHMg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@napi-rs/canvas-win32-x64-msvc@0.1.80':
|
||||
resolution: {integrity: sha512-Z8jPsM6df5V8B1HrCHB05+bDiCxjE9QA//3YrkKIdVDEwn5RKaqOxCJDRJkl48cJbylcrJbW4HxZbTte8juuPg==}
|
||||
|
|
@ -2869,67 +3040,56 @@ packages:
|
|||
resolution: {integrity: sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-arm-musleabihf@4.54.0':
|
||||
resolution: {integrity: sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-arm64-gnu@4.54.0':
|
||||
resolution: {integrity: sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-arm64-musl@4.54.0':
|
||||
resolution: {integrity: sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-loong64-gnu@4.54.0':
|
||||
resolution: {integrity: sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw==}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-ppc64-gnu@4.54.0':
|
||||
resolution: {integrity: sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-gnu@4.54.0':
|
||||
resolution: {integrity: sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-musl@4.54.0':
|
||||
resolution: {integrity: sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-s390x-gnu@4.54.0':
|
||||
resolution: {integrity: sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-x64-gnu@4.54.0':
|
||||
resolution: {integrity: sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-x64-musl@4.54.0':
|
||||
resolution: {integrity: sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-openharmony-arm64@4.54.0':
|
||||
resolution: {integrity: sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg==}
|
||||
|
|
@ -3251,28 +3411,24 @@ packages:
|
|||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@tailwindcss/oxide-linux-arm64-musl@4.1.18':
|
||||
resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@tailwindcss/oxide-linux-x64-gnu@4.1.18':
|
||||
resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@tailwindcss/oxide-linux-x64-musl@4.1.18':
|
||||
resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@tailwindcss/oxide-wasm32-wasi@4.1.18':
|
||||
resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==}
|
||||
|
|
@ -3408,12 +3564,6 @@ packages:
|
|||
peerDependencies:
|
||||
'@tiptap/extension-list': 3.22.5
|
||||
|
||||
'@tiptap/extension-list@3.22.4':
|
||||
resolution: {integrity: sha512-Xe8UFvvHmyp/c/TJsFwlwU9CWACYbBirNsluJ3U1+H8BTu1wqdrT/AXR5uIXeyCl5kiWKgX5q71eHWbYFOrqrg==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': 3.22.4
|
||||
'@tiptap/pm': 3.22.4
|
||||
|
||||
'@tiptap/extension-list@3.22.5':
|
||||
resolution: {integrity: sha512-cVO3ZHCgxAWZ4zrFSs81FO2nyCk1wb2EHkpLpW98FzbJLkN9rDkazhW99P3HRWy/CvUldOT+8ecI1YrQtBojMg==}
|
||||
peerDependencies:
|
||||
|
|
@ -3466,12 +3616,6 @@ packages:
|
|||
peerDependencies:
|
||||
'@tiptap/core': 3.22.5
|
||||
|
||||
'@tiptap/extensions@3.22.4':
|
||||
resolution: {integrity: sha512-fOe8VptJvLPs32bNdUYo8SRyljwqKNQVXWW056VoXIc5en/59OdJlJQVeHI0jRRciH3MtrqODi/gfJR0VHNZ8A==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': 3.22.4
|
||||
'@tiptap/pm': 3.22.4
|
||||
|
||||
'@tiptap/extensions@3.22.5':
|
||||
resolution: {integrity: sha512-Ifg4MzKCj3uRqe3ieTwYnomu2y4p7EXr2avVSKZYfh12i2dyWe2Gkn1KuZDREANVE+gHqFlQjJRYzhJFwzSCrg==}
|
||||
peerDependencies:
|
||||
|
|
@ -3932,6 +4076,12 @@ packages:
|
|||
resolution: {integrity: sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==}
|
||||
engines: {node: '>=14.6'}
|
||||
|
||||
'@xterm/addon-fit@0.11.0':
|
||||
resolution: {integrity: sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==}
|
||||
|
||||
'@xterm/xterm@6.0.0':
|
||||
resolution: {integrity: sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==}
|
||||
|
||||
'@xtuc/ieee754@1.2.0':
|
||||
resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==}
|
||||
|
||||
|
|
@ -4357,6 +4507,9 @@ packages:
|
|||
react: ^18 || ^19 || ^19.0.0-rc
|
||||
react-dom: ^18 || ^19 || ^19.0.0-rc
|
||||
|
||||
codemirror@6.0.2:
|
||||
resolution: {integrity: sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==}
|
||||
|
||||
codepage@1.15.0:
|
||||
resolution: {integrity: sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==}
|
||||
engines: {node: '>=0.8'}
|
||||
|
|
@ -4462,6 +4615,9 @@ packages:
|
|||
engines: {node: '>=0.8'}
|
||||
hasBin: true
|
||||
|
||||
crelt@1.0.6:
|
||||
resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==}
|
||||
|
||||
cron-parser@5.5.0:
|
||||
resolution: {integrity: sha512-oML4lKUXxizYswqmxuOCpgFS8BNUJpIu6k/2HVHyaL8Ynnf3wdf9tkns0yRdJLSIjkJ+b0DXHMZEHGpMwjnPww==}
|
||||
engines: {node: '>=18'}
|
||||
|
|
@ -5973,28 +6129,24 @@ packages:
|
|||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
lightningcss-linux-arm64-musl@1.30.2:
|
||||
resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
lightningcss-linux-x64-gnu@1.30.2:
|
||||
resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
lightningcss-linux-x64-musl@1.30.2:
|
||||
resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
lightningcss-win32-arm64-msvc@1.30.2:
|
||||
resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==}
|
||||
|
|
@ -6525,6 +6677,9 @@ packages:
|
|||
resolution: {integrity: sha512-sn9Et4N3ynsetj3spsZR729DVlGH6iBG4RiDMV7HEp3guyOW6W3S0unGpLDxT50mXortGUMax/ykUNQXdqc/Xg==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
node-addon-api@7.1.1:
|
||||
resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==}
|
||||
|
||||
node-api-version@0.2.1:
|
||||
resolution: {integrity: sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q==}
|
||||
|
||||
|
|
@ -6553,6 +6708,9 @@ packages:
|
|||
node-html-parser@6.1.13:
|
||||
resolution: {integrity: sha512-qIsTMOY4C/dAa5Q5vsobRpOOvPfC4pB61UVW2uSwZNUp0QU/jCekTal1vMmbO0DgdHeLUJpv/ARmDqErVxA3Sg==}
|
||||
|
||||
node-pty@1.1.0:
|
||||
resolution: {integrity: sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==}
|
||||
|
||||
node-releases@2.0.27:
|
||||
resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==}
|
||||
|
||||
|
|
@ -7574,6 +7732,9 @@ packages:
|
|||
strnum@2.1.2:
|
||||
resolution: {integrity: sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==}
|
||||
|
||||
style-mod@4.1.3:
|
||||
resolution: {integrity: sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==}
|
||||
|
||||
style-to-js@1.1.21:
|
||||
resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==}
|
||||
|
||||
|
|
@ -9003,6 +9164,265 @@ snapshots:
|
|||
|
||||
'@chevrotain/utils@12.0.0': {}
|
||||
|
||||
'@codemirror/autocomplete@6.20.3':
|
||||
dependencies:
|
||||
'@codemirror/language': 6.12.3
|
||||
'@codemirror/state': 6.6.0
|
||||
'@codemirror/view': 6.43.1
|
||||
'@lezer/common': 1.5.2
|
||||
|
||||
'@codemirror/commands@6.10.3':
|
||||
dependencies:
|
||||
'@codemirror/language': 6.12.3
|
||||
'@codemirror/state': 6.6.0
|
||||
'@codemirror/view': 6.43.1
|
||||
'@lezer/common': 1.5.2
|
||||
|
||||
'@codemirror/lang-angular@0.1.4':
|
||||
dependencies:
|
||||
'@codemirror/lang-html': 6.4.11
|
||||
'@codemirror/lang-javascript': 6.2.5
|
||||
'@codemirror/language': 6.12.3
|
||||
'@lezer/common': 1.5.2
|
||||
'@lezer/highlight': 1.2.3
|
||||
'@lezer/lr': 1.4.10
|
||||
|
||||
'@codemirror/lang-cpp@6.0.3':
|
||||
dependencies:
|
||||
'@codemirror/language': 6.12.3
|
||||
'@lezer/cpp': 1.1.6
|
||||
|
||||
'@codemirror/lang-css@6.3.1':
|
||||
dependencies:
|
||||
'@codemirror/autocomplete': 6.20.3
|
||||
'@codemirror/language': 6.12.3
|
||||
'@codemirror/state': 6.6.0
|
||||
'@lezer/common': 1.5.2
|
||||
'@lezer/css': 1.3.3
|
||||
|
||||
'@codemirror/lang-go@6.0.1':
|
||||
dependencies:
|
||||
'@codemirror/autocomplete': 6.20.3
|
||||
'@codemirror/language': 6.12.3
|
||||
'@codemirror/state': 6.6.0
|
||||
'@lezer/common': 1.5.2
|
||||
'@lezer/go': 1.0.1
|
||||
|
||||
'@codemirror/lang-html@6.4.11':
|
||||
dependencies:
|
||||
'@codemirror/autocomplete': 6.20.3
|
||||
'@codemirror/lang-css': 6.3.1
|
||||
'@codemirror/lang-javascript': 6.2.5
|
||||
'@codemirror/language': 6.12.3
|
||||
'@codemirror/state': 6.6.0
|
||||
'@codemirror/view': 6.43.1
|
||||
'@lezer/common': 1.5.2
|
||||
'@lezer/css': 1.3.3
|
||||
'@lezer/html': 1.3.13
|
||||
|
||||
'@codemirror/lang-java@6.0.2':
|
||||
dependencies:
|
||||
'@codemirror/language': 6.12.3
|
||||
'@lezer/java': 1.1.3
|
||||
|
||||
'@codemirror/lang-javascript@6.2.5':
|
||||
dependencies:
|
||||
'@codemirror/autocomplete': 6.20.3
|
||||
'@codemirror/language': 6.12.3
|
||||
'@codemirror/lint': 6.9.7
|
||||
'@codemirror/state': 6.6.0
|
||||
'@codemirror/view': 6.43.1
|
||||
'@lezer/common': 1.5.2
|
||||
'@lezer/javascript': 1.5.4
|
||||
|
||||
'@codemirror/lang-jinja@6.0.1':
|
||||
dependencies:
|
||||
'@codemirror/autocomplete': 6.20.3
|
||||
'@codemirror/lang-html': 6.4.11
|
||||
'@codemirror/language': 6.12.3
|
||||
'@codemirror/state': 6.6.0
|
||||
'@codemirror/view': 6.43.1
|
||||
'@lezer/common': 1.5.2
|
||||
'@lezer/highlight': 1.2.3
|
||||
'@lezer/lr': 1.4.10
|
||||
|
||||
'@codemirror/lang-json@6.0.2':
|
||||
dependencies:
|
||||
'@codemirror/language': 6.12.3
|
||||
'@lezer/json': 1.0.3
|
||||
|
||||
'@codemirror/lang-less@6.0.2':
|
||||
dependencies:
|
||||
'@codemirror/lang-css': 6.3.1
|
||||
'@codemirror/language': 6.12.3
|
||||
'@lezer/common': 1.5.2
|
||||
'@lezer/highlight': 1.2.3
|
||||
'@lezer/lr': 1.4.10
|
||||
|
||||
'@codemirror/lang-liquid@6.3.2':
|
||||
dependencies:
|
||||
'@codemirror/autocomplete': 6.20.3
|
||||
'@codemirror/lang-html': 6.4.11
|
||||
'@codemirror/language': 6.12.3
|
||||
'@codemirror/state': 6.6.0
|
||||
'@codemirror/view': 6.43.1
|
||||
'@lezer/common': 1.5.2
|
||||
'@lezer/highlight': 1.2.3
|
||||
'@lezer/lr': 1.4.10
|
||||
|
||||
'@codemirror/lang-markdown@6.5.0':
|
||||
dependencies:
|
||||
'@codemirror/autocomplete': 6.20.3
|
||||
'@codemirror/lang-html': 6.4.11
|
||||
'@codemirror/language': 6.12.3
|
||||
'@codemirror/state': 6.6.0
|
||||
'@codemirror/view': 6.43.1
|
||||
'@lezer/common': 1.5.2
|
||||
'@lezer/markdown': 1.6.4
|
||||
|
||||
'@codemirror/lang-php@6.0.2':
|
||||
dependencies:
|
||||
'@codemirror/lang-html': 6.4.11
|
||||
'@codemirror/language': 6.12.3
|
||||
'@codemirror/state': 6.6.0
|
||||
'@lezer/common': 1.5.2
|
||||
'@lezer/php': 1.0.5
|
||||
|
||||
'@codemirror/lang-python@6.2.1':
|
||||
dependencies:
|
||||
'@codemirror/autocomplete': 6.20.3
|
||||
'@codemirror/language': 6.12.3
|
||||
'@codemirror/state': 6.6.0
|
||||
'@lezer/common': 1.5.2
|
||||
'@lezer/python': 1.1.19
|
||||
|
||||
'@codemirror/lang-rust@6.0.2':
|
||||
dependencies:
|
||||
'@codemirror/language': 6.12.3
|
||||
'@lezer/rust': 1.0.2
|
||||
|
||||
'@codemirror/lang-sass@6.0.2':
|
||||
dependencies:
|
||||
'@codemirror/lang-css': 6.3.1
|
||||
'@codemirror/language': 6.12.3
|
||||
'@codemirror/state': 6.6.0
|
||||
'@lezer/common': 1.5.2
|
||||
'@lezer/sass': 1.1.0
|
||||
|
||||
'@codemirror/lang-sql@6.10.0':
|
||||
dependencies:
|
||||
'@codemirror/autocomplete': 6.20.3
|
||||
'@codemirror/language': 6.12.3
|
||||
'@codemirror/state': 6.6.0
|
||||
'@lezer/common': 1.5.2
|
||||
'@lezer/highlight': 1.2.3
|
||||
'@lezer/lr': 1.4.10
|
||||
|
||||
'@codemirror/lang-vue@0.1.3':
|
||||
dependencies:
|
||||
'@codemirror/lang-html': 6.4.11
|
||||
'@codemirror/lang-javascript': 6.2.5
|
||||
'@codemirror/language': 6.12.3
|
||||
'@lezer/common': 1.5.2
|
||||
'@lezer/highlight': 1.2.3
|
||||
'@lezer/lr': 1.4.10
|
||||
|
||||
'@codemirror/lang-wast@6.0.2':
|
||||
dependencies:
|
||||
'@codemirror/language': 6.12.3
|
||||
'@lezer/common': 1.5.2
|
||||
'@lezer/highlight': 1.2.3
|
||||
'@lezer/lr': 1.4.10
|
||||
|
||||
'@codemirror/lang-xml@6.1.0':
|
||||
dependencies:
|
||||
'@codemirror/autocomplete': 6.20.3
|
||||
'@codemirror/language': 6.12.3
|
||||
'@codemirror/state': 6.6.0
|
||||
'@codemirror/view': 6.43.1
|
||||
'@lezer/common': 1.5.2
|
||||
'@lezer/xml': 1.0.6
|
||||
|
||||
'@codemirror/lang-yaml@6.1.3':
|
||||
dependencies:
|
||||
'@codemirror/autocomplete': 6.20.3
|
||||
'@codemirror/language': 6.12.3
|
||||
'@codemirror/state': 6.6.0
|
||||
'@lezer/common': 1.5.2
|
||||
'@lezer/highlight': 1.2.3
|
||||
'@lezer/lr': 1.4.10
|
||||
'@lezer/yaml': 1.0.4
|
||||
|
||||
'@codemirror/language-data@6.5.2':
|
||||
dependencies:
|
||||
'@codemirror/lang-angular': 0.1.4
|
||||
'@codemirror/lang-cpp': 6.0.3
|
||||
'@codemirror/lang-css': 6.3.1
|
||||
'@codemirror/lang-go': 6.0.1
|
||||
'@codemirror/lang-html': 6.4.11
|
||||
'@codemirror/lang-java': 6.0.2
|
||||
'@codemirror/lang-javascript': 6.2.5
|
||||
'@codemirror/lang-jinja': 6.0.1
|
||||
'@codemirror/lang-json': 6.0.2
|
||||
'@codemirror/lang-less': 6.0.2
|
||||
'@codemirror/lang-liquid': 6.3.2
|
||||
'@codemirror/lang-markdown': 6.5.0
|
||||
'@codemirror/lang-php': 6.0.2
|
||||
'@codemirror/lang-python': 6.2.1
|
||||
'@codemirror/lang-rust': 6.0.2
|
||||
'@codemirror/lang-sass': 6.0.2
|
||||
'@codemirror/lang-sql': 6.10.0
|
||||
'@codemirror/lang-vue': 0.1.3
|
||||
'@codemirror/lang-wast': 6.0.2
|
||||
'@codemirror/lang-xml': 6.1.0
|
||||
'@codemirror/lang-yaml': 6.1.3
|
||||
'@codemirror/language': 6.12.3
|
||||
'@codemirror/legacy-modes': 6.5.3
|
||||
|
||||
'@codemirror/language@6.12.3':
|
||||
dependencies:
|
||||
'@codemirror/state': 6.6.0
|
||||
'@codemirror/view': 6.43.1
|
||||
'@lezer/common': 1.5.2
|
||||
'@lezer/highlight': 1.2.3
|
||||
'@lezer/lr': 1.4.10
|
||||
style-mod: 4.1.3
|
||||
|
||||
'@codemirror/legacy-modes@6.5.3':
|
||||
dependencies:
|
||||
'@codemirror/language': 6.12.3
|
||||
|
||||
'@codemirror/lint@6.9.7':
|
||||
dependencies:
|
||||
'@codemirror/state': 6.6.0
|
||||
'@codemirror/view': 6.43.1
|
||||
crelt: 1.0.6
|
||||
|
||||
'@codemirror/merge@6.12.2':
|
||||
dependencies:
|
||||
'@codemirror/language': 6.12.3
|
||||
'@codemirror/state': 6.6.0
|
||||
'@codemirror/view': 6.43.1
|
||||
'@lezer/highlight': 1.2.3
|
||||
style-mod: 4.1.3
|
||||
|
||||
'@codemirror/search@6.7.0':
|
||||
dependencies:
|
||||
'@codemirror/state': 6.6.0
|
||||
'@codemirror/view': 6.43.1
|
||||
crelt: 1.0.6
|
||||
|
||||
'@codemirror/state@6.6.0':
|
||||
dependencies:
|
||||
'@marijn/find-cluster-break': 1.0.2
|
||||
|
||||
'@codemirror/view@6.43.1':
|
||||
dependencies:
|
||||
'@codemirror/state': 6.6.0
|
||||
crelt: 1.0.6
|
||||
style-mod: 4.1.3
|
||||
w3c-keyname: 2.2.8
|
||||
|
||||
'@composio/client@0.1.0-alpha.56': {}
|
||||
|
||||
'@composio/core@0.6.2(zod@4.2.1)':
|
||||
|
|
@ -9890,6 +10310,99 @@ snapshots:
|
|||
'@jridgewell/resolve-uri': 3.1.2
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
'@lezer/common@1.5.2': {}
|
||||
|
||||
'@lezer/cpp@1.1.6':
|
||||
dependencies:
|
||||
'@lezer/common': 1.5.2
|
||||
'@lezer/highlight': 1.2.3
|
||||
'@lezer/lr': 1.4.10
|
||||
|
||||
'@lezer/css@1.3.3':
|
||||
dependencies:
|
||||
'@lezer/common': 1.5.2
|
||||
'@lezer/highlight': 1.2.3
|
||||
'@lezer/lr': 1.4.10
|
||||
|
||||
'@lezer/go@1.0.1':
|
||||
dependencies:
|
||||
'@lezer/common': 1.5.2
|
||||
'@lezer/highlight': 1.2.3
|
||||
'@lezer/lr': 1.4.10
|
||||
|
||||
'@lezer/highlight@1.2.3':
|
||||
dependencies:
|
||||
'@lezer/common': 1.5.2
|
||||
|
||||
'@lezer/html@1.3.13':
|
||||
dependencies:
|
||||
'@lezer/common': 1.5.2
|
||||
'@lezer/highlight': 1.2.3
|
||||
'@lezer/lr': 1.4.10
|
||||
|
||||
'@lezer/java@1.1.3':
|
||||
dependencies:
|
||||
'@lezer/common': 1.5.2
|
||||
'@lezer/highlight': 1.2.3
|
||||
'@lezer/lr': 1.4.10
|
||||
|
||||
'@lezer/javascript@1.5.4':
|
||||
dependencies:
|
||||
'@lezer/common': 1.5.2
|
||||
'@lezer/highlight': 1.2.3
|
||||
'@lezer/lr': 1.4.10
|
||||
|
||||
'@lezer/json@1.0.3':
|
||||
dependencies:
|
||||
'@lezer/common': 1.5.2
|
||||
'@lezer/highlight': 1.2.3
|
||||
'@lezer/lr': 1.4.10
|
||||
|
||||
'@lezer/lr@1.4.10':
|
||||
dependencies:
|
||||
'@lezer/common': 1.5.2
|
||||
|
||||
'@lezer/markdown@1.6.4':
|
||||
dependencies:
|
||||
'@lezer/common': 1.5.2
|
||||
'@lezer/highlight': 1.2.3
|
||||
|
||||
'@lezer/php@1.0.5':
|
||||
dependencies:
|
||||
'@lezer/common': 1.5.2
|
||||
'@lezer/highlight': 1.2.3
|
||||
'@lezer/lr': 1.4.10
|
||||
|
||||
'@lezer/python@1.1.19':
|
||||
dependencies:
|
||||
'@lezer/common': 1.5.2
|
||||
'@lezer/highlight': 1.2.3
|
||||
'@lezer/lr': 1.4.10
|
||||
|
||||
'@lezer/rust@1.0.2':
|
||||
dependencies:
|
||||
'@lezer/common': 1.5.2
|
||||
'@lezer/highlight': 1.2.3
|
||||
'@lezer/lr': 1.4.10
|
||||
|
||||
'@lezer/sass@1.1.0':
|
||||
dependencies:
|
||||
'@lezer/common': 1.5.2
|
||||
'@lezer/highlight': 1.2.3
|
||||
'@lezer/lr': 1.4.10
|
||||
|
||||
'@lezer/xml@1.0.6':
|
||||
dependencies:
|
||||
'@lezer/common': 1.5.2
|
||||
'@lezer/highlight': 1.2.3
|
||||
'@lezer/lr': 1.4.10
|
||||
|
||||
'@lezer/yaml@1.0.4':
|
||||
dependencies:
|
||||
'@lezer/common': 1.5.2
|
||||
'@lezer/highlight': 1.2.3
|
||||
'@lezer/lr': 1.4.10
|
||||
|
||||
'@listr2/prompt-adapter-inquirer@2.0.22(@inquirer/prompts@6.0.1)':
|
||||
dependencies:
|
||||
'@inquirer/prompts': 6.0.1
|
||||
|
|
@ -9904,6 +10417,8 @@ snapshots:
|
|||
dependencies:
|
||||
cross-spawn: 7.0.6
|
||||
|
||||
'@marijn/find-cluster-break@1.0.2': {}
|
||||
|
||||
'@mermaid-js/parser@1.1.0':
|
||||
dependencies:
|
||||
langium: 4.2.2
|
||||
|
|
@ -11783,11 +12298,6 @@ snapshots:
|
|||
dependencies:
|
||||
'@tiptap/extension-list': 3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)
|
||||
|
||||
'@tiptap/extension-list@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.22.4(@tiptap/pm@3.22.4)
|
||||
'@tiptap/pm': 3.22.4
|
||||
|
||||
'@tiptap/extension-list@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.22.4(@tiptap/pm@3.22.4)
|
||||
|
|
@ -11801,9 +12311,9 @@ snapshots:
|
|||
dependencies:
|
||||
'@tiptap/core': 3.22.4(@tiptap/pm@3.22.4)
|
||||
|
||||
'@tiptap/extension-placeholder@3.22.4(@tiptap/extensions@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))':
|
||||
'@tiptap/extension-placeholder@3.22.4(@tiptap/extensions@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))':
|
||||
dependencies:
|
||||
'@tiptap/extensions': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)
|
||||
'@tiptap/extensions': 3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)
|
||||
|
||||
'@tiptap/extension-strike@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))':
|
||||
dependencies:
|
||||
|
|
@ -11814,13 +12324,13 @@ snapshots:
|
|||
'@tiptap/core': 3.22.4(@tiptap/pm@3.22.4)
|
||||
'@tiptap/pm': 3.22.4
|
||||
|
||||
'@tiptap/extension-task-item@3.22.4(@tiptap/extension-list@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))':
|
||||
'@tiptap/extension-task-item@3.22.4(@tiptap/extension-list@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))':
|
||||
dependencies:
|
||||
'@tiptap/extension-list': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)
|
||||
'@tiptap/extension-list': 3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)
|
||||
|
||||
'@tiptap/extension-task-list@3.22.4(@tiptap/extension-list@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))':
|
||||
'@tiptap/extension-task-list@3.22.4(@tiptap/extension-list@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4))':
|
||||
dependencies:
|
||||
'@tiptap/extension-list': 3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)
|
||||
'@tiptap/extension-list': 3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)
|
||||
|
||||
'@tiptap/extension-text@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))':
|
||||
dependencies:
|
||||
|
|
@ -11830,11 +12340,6 @@ snapshots:
|
|||
dependencies:
|
||||
'@tiptap/core': 3.22.4(@tiptap/pm@3.22.4)
|
||||
|
||||
'@tiptap/extensions@3.22.4(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.22.4(@tiptap/pm@3.22.4)
|
||||
'@tiptap/pm': 3.22.4
|
||||
|
||||
'@tiptap/extensions@3.22.5(@tiptap/core@3.22.4(@tiptap/pm@3.22.4))(@tiptap/pm@3.22.4)':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.22.4(@tiptap/pm@3.22.4)
|
||||
|
|
@ -12470,6 +12975,10 @@ snapshots:
|
|||
|
||||
'@xmldom/xmldom@0.9.10': {}
|
||||
|
||||
'@xterm/addon-fit@0.11.0': {}
|
||||
|
||||
'@xterm/xterm@6.0.0': {}
|
||||
|
||||
'@xtuc/ieee754@1.2.0': {}
|
||||
|
||||
'@xtuc/long@4.2.2': {}
|
||||
|
|
@ -12921,6 +13430,16 @@ snapshots:
|
|||
- '@types/react'
|
||||
- '@types/react-dom'
|
||||
|
||||
codemirror@6.0.2:
|
||||
dependencies:
|
||||
'@codemirror/autocomplete': 6.20.3
|
||||
'@codemirror/commands': 6.10.3
|
||||
'@codemirror/language': 6.12.3
|
||||
'@codemirror/lint': 6.9.7
|
||||
'@codemirror/search': 6.7.0
|
||||
'@codemirror/state': 6.6.0
|
||||
'@codemirror/view': 6.43.1
|
||||
|
||||
codepage@1.15.0: {}
|
||||
|
||||
color-convert@0.5.3:
|
||||
|
|
@ -13001,6 +13520,8 @@ snapshots:
|
|||
|
||||
crc-32@1.2.2: {}
|
||||
|
||||
crelt@1.0.6: {}
|
||||
|
||||
cron-parser@5.5.0:
|
||||
dependencies:
|
||||
luxon: 3.7.2
|
||||
|
|
@ -15626,6 +16147,8 @@ snapshots:
|
|||
dependencies:
|
||||
semver: 7.7.3
|
||||
|
||||
node-addon-api@7.1.1: {}
|
||||
|
||||
node-api-version@0.2.1:
|
||||
dependencies:
|
||||
semver: 7.7.3
|
||||
|
|
@ -15653,6 +16176,10 @@ snapshots:
|
|||
css-select: 5.2.2
|
||||
he: 1.2.0
|
||||
|
||||
node-pty@1.1.0:
|
||||
dependencies:
|
||||
node-addon-api: 7.1.1
|
||||
|
||||
node-releases@2.0.27: {}
|
||||
|
||||
nopt@6.0.0:
|
||||
|
|
@ -16882,6 +17409,8 @@ snapshots:
|
|||
|
||||
strnum@2.1.2: {}
|
||||
|
||||
style-mod@4.1.3: {}
|
||||
|
||||
style-to-js@1.1.21:
|
||||
dependencies:
|
||||
style-to-object: 1.0.14
|
||||
|
|
|
|||
|
|
@ -2,6 +2,15 @@ packages:
|
|||
- apps/*
|
||||
- packages/*
|
||||
|
||||
allowBuilds:
|
||||
core-js: set this to true or false
|
||||
electron: set this to true or false
|
||||
electron-winstaller: set this to true or false
|
||||
esbuild: set this to true or false
|
||||
fs-xattr: set this to true or false
|
||||
macos-alias: set this to true or false
|
||||
protobufjs: set this to true or false
|
||||
|
||||
catalog:
|
||||
vitest: 4.1.7
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue