diff --git a/apps/x/ANALYTICS.md b/apps/x/ANALYTICS.md index 2d9816d0..5ddfcf6e 100644 --- a/apps/x/ANALYTICS.md +++ b/apps/x/ANALYTICS.md @@ -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 diff --git a/apps/x/apps/main/bundle.mjs b/apps/x/apps/main/bundle.mjs index 976e8db3..69bb2e1b 100644 --- a/apps/x/apps/main/bundle.mjs +++ b/apps/x/apps/main/bundle.mjs @@ -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'); diff --git a/apps/x/apps/main/forge.config.cjs b/apps/x/apps/main/forge.config.cjs index b3660a6b..b202888a 100644 --- a/apps/x/apps/main/forge.config.cjs +++ b/apps/x/apps/main/forge.config.cjs @@ -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"], diff --git a/apps/x/apps/main/makers/maker-pacman.cjs b/apps/x/apps/main/makers/maker-pacman.cjs new file mode 100644 index 00000000..4cae1da9 --- /dev/null +++ b/apps/x/apps/main/makers/maker-pacman.cjs @@ -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; diff --git a/apps/x/apps/main/package.json b/apps/x/apps/main/package.json index 3330c3c0..4b55ab2a 100644 --- a/apps/x/apps/main/package.json +++ b/apps/x/apps/main/package.json @@ -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", diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index e5d407f8..e59b1994 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -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 { + const repo = container.resolve('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 { + if (codeSessionStatusWatcher) { + return; + } + const tracker = container.resolve('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 { 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('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('codeProjectsRepo'); + await repo.remove(args.projectId); + return { success: true }; + }, + 'codeProject:list': async () => { + const repo = container.resolve('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'); + const session = await service.create(args); + return { session }; + }, + 'codeSession:list': async () => { + const repo = container.resolve('codeSessionsRepo'); + const tracker = container.resolve('codeSessionStatusTracker'); + return { sessions: await repo.list(), statuses: tracker.getStatuses() }; + }, + 'codeSession:update': async (_event, args) => { + const service = container.resolve('codeSessionService'); + return { session: await service.update(args.sessionId, args.patch) }; + }, + 'codeSession:delete': async (_event, args) => { + const service = container.resolve('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'); + // 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'); + 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'); + return service.mergeBack(args.sessionId); + }, + 'codeSession:cleanupWorktree': async (_event, args) => { + const service = container.resolve('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('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, }); diff --git a/apps/x/apps/main/src/main.ts b/apps/x/apps/main/src/main.ts index f4415b5d..8c70f610 100644 --- a/apps/x/apps/main/src/main.ts +++ b/apps/x/apps/main/src/main.ts @@ -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); }); diff --git a/apps/x/apps/main/src/notification/electron-notification-service.ts b/apps/x/apps/main/src/notification/electron-notification-service.ts index dd37e37d..d86a4898 100644 --- a/apps/x/apps/main/src/notification/electron-notification-service.ts +++ b/apps/x/apps/main/src/notification/electron-notification-service.ts @@ -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. diff --git a/apps/x/apps/main/src/terminal.ts b/apps/x/apps/main/src/terminal.ts new file mode 100644 index 00000000..83d5a7c9 --- /dev/null +++ b/apps/x/apps/main/src/terminal.ts @@ -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(); + +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, + }); + 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); +} diff --git a/apps/x/apps/renderer/package.json b/apps/x/apps/renderer/package.json index 67876189..eec078d6 100644 --- a/apps/x/apps/renderer/package.json +++ b/apps/x/apps/renderer/package.json @@ -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", diff --git a/apps/x/apps/renderer/src/App.css b/apps/x/apps/renderer/src/App.css index 86c6535d..02cfd7bd 100644 --- a/apps/x/apps/renderer/src/App.css +++ b/apps/x/apps/renderer/src/App.css @@ -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 { diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index b850b57f..fcbc1ec7 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -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([]) // Chat tab state @@ -1159,6 +1167,23 @@ function App() { const [activeFileTabId, setActiveFileTabId] = useState('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(null) + // A file the code chat asked to review — consumed by the workspace pane. + const [codeDiffPath, setCodeDiffPath] = useState(null) + const boundCodeSessionRef = useRef(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>({}) const [editorSessionByTabId, setEditorSessionByTabId] = useState>({}) const fileHistoryHandlersRef = useRef>(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(() => { @@ -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 ? ( 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 ? ( Version history )} - {!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 && ( )} -
-
- {loading ? ( -
- Loading… -
- ) : isEmpty ? ( -

- No output yet. Click Run now in the sidebar, or wait for a trigger to fire. -

- ) : viewSource ? ( -
{body}
- ) : ( - - )} + {showHtml ? ( + // Full-bleed: the iframe fills the pane and scrolls internally. + // Remount on refreshKey so a re-run's updated index.html reloads. + + ) : ( +
+
+ {loading ? ( +
+ Loading… +
+ ) : isEmpty ? ( +

+ No output yet. Click Run now in the sidebar, or wait for a trigger to fire. +

+ ) : viewSource ? ( +
{body}
+ ) : ( + + )} +
-
+ )}
) } diff --git a/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx b/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx index 0254cdfd..fcad45b4 100644 --- a/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx +++ b/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx @@ -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 (
@@ -820,7 +842,7 @@ function ChatInputInner({ - {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'} @@ -830,8 +852,21 @@ function ChatInputInner({ Add files or photos - {/* 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 ? ( +
+ + + {currentWorkDirLabel} + Pinned by the coding session + +
+ ) : ( + /* 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. */ @@ -907,26 +942,31 @@ function ChatInputInner({ )} + )}
- {workDir && collapseLevel < 8 && ( + {effectiveWorkDir && collapseLevel < 8 && ( {/* Level 4: collapse to a square icon */}
= 4 ? "w-7 justify-center" : "max-w-[180px] pl-2.5 pr-2" )}> - {collapseLevel < 4 && ( + {collapseLevel < 4 && !isCodeLocked && ( - Code mode on ({codingAgent === 'claude' ? 'Claude Code' : 'Codex'}) — click to disable + + {isCodeLocked + ? `Coding session — ${codingAgent === 'claude' ? 'Claude Code' : 'Codex'}` + : `Code mode on (${codingAgent === 'claude' ? 'Claude Code' : 'Codex'}) — click to disable`} + ) : (
@@ -1018,14 +1068,20 @@ function ChatInputInner({ - Code mode on — click to disable + + {isCodeLocked ? 'Pinned by the coding session' : 'Code mode on — click to disable'} + · @@ -1033,13 +1089,19 @@ function ChatInputInner({ - 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`}
@@ -1077,10 +1139,10 @@ function ChatInputInner({ More options - {workDir && collapseLevel >= 8 && ( - { void handleSetWorkDir() }}> - - {basename(workDir) || workDir} + {effectiveWorkDir && collapseLevel >= 8 && ( + { void handleSetWorkDir() }}> + {isCodeLocked ? : } + {basename(effectiveWorkDir) || effectiveWorkDir} )} {searchAvailable && collapseLevel >= 7 && ( @@ -1105,14 +1167,15 @@ function ChatInputInner({ {codeModeFeatureEnabled && collapseLevel >= 5 && ( <> e.preventDefault()} onCheckedChange={(c) => setCodeModeEnabled(Boolean(c))} > Code mode - {codeModeEnabled && ( - { e.preventDefault(); handleToggleCodingAgent() }}> + {(isCodeLocked || codeModeEnabled) && ( + { e.preventDefault(); handleToggleCodingAgent() }}> Coding agent {codingAgent === 'claude' ? 'Claude' : 'Codex'} @@ -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 ( @@ -1368,6 +1434,7 @@ export function ChatInputWithMentions({ onSelectedModelChange={onSelectedModelChange} workDir={workDir} onWorkDirChange={onWorkDirChange} + codeSessionLock={codeSessionLock} /> ) diff --git a/apps/x/apps/renderer/src/components/chat-sidebar.tsx b/apps/x/apps/renderer/src/components/chat-sidebar.tsx index 6300f4cc..0f89570f 100644 --- a/apps/x/apps/renderer/src/components/chat-sidebar.tsx +++ b/apps/x/apps/renderer/src/components/chat-sidebar.tsx @@ -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 + /** Composer locks for runs bound to Code-section sessions (cwd + agent frozen). */ + codeSessionLocks?: Record + /** + * 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, }} > - { - 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 ? ( + + +
+ + {pinnedToCodeSession.title} + + Coding session + +
+
+ + This chat is pinned to the coding session — leave the Code view to switch chats. + +
+ ) : ( + { + const activeTab = chatTabs.find((tab) => tab.id === activeChatTabId) + return activeTab ? getChatTabTitle(activeTab) : 'New chat' + })()} + onNewChatTab={onNewChatTab} + recentRuns={recentRuns} + activeRunId={runId} + onSelectRun={onSelectRun} + onOpenChatHistory={onOpenChatHistory} + /> + )} @@ -646,9 +672,11 @@ export function ChatSidebar({ {!tabHasConversation ? ( ) : ( @@ -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} diff --git a/apps/x/apps/renderer/src/components/code/cm.ts b/apps/x/apps/renderer/src/components/code/cm.ts new file mode 100644 index 00000000..4b75c1b2 --- /dev/null +++ b/apps/x/apps/renderer/src/components/code/cm.ts @@ -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 { + const desc = LanguageDescription.matchFilename(languages, filename) + if (!desc) return null + try { + return await desc.load() + } catch { + return null + } +} diff --git a/apps/x/apps/renderer/src/components/code/code-chat.tsx b/apps/x/apps/renderer/src/components/code/code-chat.tsx new file mode 100644 index 00000000..7b1656f3 --- /dev/null +++ b/apps/x/apps/renderer/src/components/code/code-chat.tsx @@ -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 = { 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 ( + + ) + } + if (item.name === 'code_agent_run') { + const agent = (item.result as { agent?: string } | undefined)?.agent + ?? (item.input as { agent?: string } | undefined)?.agent + return ( + + + + + + + ) + } + return ( +
+ {item.status === 'running' || item.status === 'pending' + ? + : } + {getToolDisplayName(item)} +
+ ) +} + +function ChatItem({ item, onOpenDiff }: { item: CodeChatItem; onOpenDiff: (path: string) => void }) { + if (isDirectTurn(item)) { + if (item.events.length === 0) return null + return ( +
+ +
+ ) + } + if (isChatErrorMessage(item)) { + return ( +
+ {item.message.split('\n')[0]} +
+ ) + } + if (isChatToolCall(item)) { + return + } + if (item.role === 'user') { + return ( +
+
+ {item.content} +
+
+ ) + } + return ( +
+ {item.content} +
+ ) +} + +// 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(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([]) + + 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 ( +
{ if (e.dataTransfer?.types?.includes('Files')) e.preventDefault() }} + onDrop={handleDrop} + > + {/* Slim header — session controls live in the Code view's middle header */} +
+ +
+
{session.title}
+
{AGENT_LABEL[session.agent]} — direct
+
+
+ + {/* Conversation */} + + + {loading &&
Loading conversation…
} + {!loading && items.length === 0 && !busy && ( +
+
+ Talk directly to {AGENT_LABEL[session.agent]} +
+

+ Your messages go straight to the coding agent in this project. Tool calls, plans, and diffs stream in here. +

+
+ )} + {items.map((item) => ( + + ))} + {liveText && ( +
+ {liveText.replace(/<\/?voice>/g, '')} +
+ )} + {pendingPermission && ( + void resolvePermission(d)} /> + )} + {Array.from(pendingToolPermissions.values()).map((request) => ( + 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) => ( + void respondToAskHuman(request.toolCallId, request.subflow, response)} + isProcessing={busy} + /> + ))} + {busy && !pendingPermission && pendingToolPermissions.size === 0 && pendingAskHumans.size === 0 && ( + + {stopping ? 'Stopping…' : `${AGENT_LABEL[session.agent]} is working…`} + + )} +
+ +
+ + {/* Composer — mirrors the assistant chat input's look (rounded card, + borderless textarea, round primary send / destructive stop). */} +
+
+ {attachments.length > 0 && ( +
+ {attachments.map((p) => ( + + + {basename(p)} + + + ))} +
+ )} +
+