diff --git a/.github/workflows/electron-build.yml b/.github/workflows/electron-build.yml index 6566f105..787e28e6 100644 --- a/.github/workflows/electron-build.yml +++ b/.github/workflows/electron-build.yml @@ -16,14 +16,14 @@ jobs: uses: actions/checkout@v6 - name: Setup pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@v6 with: - version: 9 + version: 10 - name: Setup Node.js uses: actions/setup-node@v6 with: - node-version: 24 + node-version: 24.15.0 cache: 'pnpm' cache-dependency-path: 'apps/x/pnpm-lock.yaml' @@ -39,17 +39,17 @@ jobs: node -e " const fs = require('fs'); const version = '${{ steps.version.outputs.version }}'; - + // Update apps/x/package.json const rootPackage = JSON.parse(fs.readFileSync('apps/x/package.json', 'utf8')); rootPackage.version = version; fs.writeFileSync('apps/x/package.json', JSON.stringify(rootPackage, null, 2) + '\n'); - + // Update apps/x/apps/main/package.json const mainPackage = JSON.parse(fs.readFileSync('apps/x/apps/main/package.json', 'utf8')); mainPackage.version = version; fs.writeFileSync('apps/x/apps/main/package.json', JSON.stringify(mainPackage, null, 2) + '\n'); - + console.log('Updated version to:', version); " @@ -61,25 +61,25 @@ jobs: # Create a temporary keychain KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db KEYCHAIN_PASSWORD=$(openssl rand -base64 32) - + # Create keychain security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" - + # Decode and import certificate echo "$APPLE_CERTIFICATE" | base64 --decode > $RUNNER_TEMP/certificate.p12 security import $RUNNER_TEMP/certificate.p12 -P "$APPLE_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k "$KEYCHAIN_PATH" - + # Allow codesign to access the keychain security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" - + # Add keychain to search list security list-keychains -d user -s "$KEYCHAIN_PATH" login.keychain - + # Verify certificate was imported security find-identity -v "$KEYCHAIN_PATH" - + # Clean up certificate file rm -f $RUNNER_TEMP/certificate.p12 @@ -111,6 +111,7 @@ jobs: with: name: distributables path: apps/x/apps/main/out/make/* + if-no-files-found: error retention-days: 30 build-linux: @@ -121,14 +122,14 @@ jobs: uses: actions/checkout@v6 - name: Setup pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@v6 with: - version: 9 + version: 10 - name: Setup Node.js uses: actions/setup-node@v6 with: - node-version: 24 + node-version: 24.15.0 cache: 'pnpm' cache-dependency-path: 'apps/x/pnpm-lock.yaml' @@ -144,17 +145,17 @@ jobs: node -e " const fs = require('fs'); const version = '${{ steps.version.outputs.version }}'; - + // Update apps/x/package.json const rootPackage = JSON.parse(fs.readFileSync('apps/x/package.json', 'utf8')); rootPackage.version = version; fs.writeFileSync('apps/x/package.json', JSON.stringify(rootPackage, null, 2) + '\n'); - + // Update apps/x/apps/main/package.json const mainPackage = JSON.parse(fs.readFileSync('apps/x/apps/main/package.json', 'utf8')); mainPackage.version = version; fs.writeFileSync('apps/x/apps/main/package.json', JSON.stringify(mainPackage, null, 2) + '\n'); - + console.log('Updated version to:', version); " @@ -175,6 +176,7 @@ jobs: with: name: distributables-linux path: apps/x/apps/main/out/make/* + if-no-files-found: error retention-days: 30 build-windows: @@ -185,14 +187,14 @@ jobs: uses: actions/checkout@v6 - name: Setup pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@v6 with: - version: 9 + version: 10 - name: Setup Node.js uses: actions/setup-node@v6 with: - node-version: 24 + node-version: 24.15.0 cache: 'pnpm' cache-dependency-path: 'apps/x/pnpm-lock.yaml' @@ -210,17 +212,17 @@ jobs: node -e " const fs = require('fs'); const version = '${{ steps.version.outputs.version }}'; - + // Update apps/x/package.json const rootPackage = JSON.parse(fs.readFileSync('apps/x/package.json', 'utf8')); rootPackage.version = version; fs.writeFileSync('apps/x/package.json', JSON.stringify(rootPackage, null, 2) + '\n'); - + // Update apps/x/apps/main/package.json const mainPackage = JSON.parse(fs.readFileSync('apps/x/apps/main/package.json', 'utf8')); mainPackage.version = version; fs.writeFileSync('apps/x/apps/main/package.json', JSON.stringify(mainPackage, null, 2) + '\n'); - + console.log('Updated version to:', version); " @@ -241,4 +243,5 @@ jobs: with: name: distributables-windows path: apps/x/apps/main/out/make/* + if-no-files-found: error retention-days: 30 diff --git a/apps/x/ANALYTICS.md b/apps/x/ANALYTICS.md index 572e9a6f..2d9816d0 100644 --- a/apps/x/ANALYTICS.md +++ b/apps/x/ANALYTICS.md @@ -16,6 +16,8 @@ ## Event catalog +All PostHog events include `app_version` automatically. Main-process events add it in `packages/core/src/analytics/posthog.ts`; renderer events get it from the `analytics:bootstrap` IPC payload and an initialization-time `before_send` hook. + ### `llm_usage` Emitted whenever ai-sdk returns token usage (one event per LLM call, not per run). @@ -101,6 +103,7 @@ Persistent across sessions for the same user. Set via `posthog.people.set` or as | `email` | main on identify | From `/v1/me`; powers PostHog cohort match + integrations | | `plan`, `status` | main on identify | Subscription state | | `api_url` | both processes (init + identify) | Distinguishes prod / staging / custom — assign meaning in PostHog dashboard. `https://api.x.rowboatlabs.com` = production | +| `app_version` | both processes (init + identify) | Electron app version; also included automatically on every event | | `signed_in` | renderer | `true` while rowboat OAuth is connected | | `{provider}_connected` | renderer | One of `gmail`, `calendar`, `slack`, `rowboat` | | `total_notes` | renderer (init) | Workspace size signal | diff --git a/apps/x/apps/main/bundle.mjs b/apps/x/apps/main/bundle.mjs index 9ae77e0e..976e8db3 100644 --- a/apps/x/apps/main/bundle.mjs +++ b/apps/x/apps/main/bundle.mjs @@ -10,11 +10,13 @@ */ import * as esbuild from 'esbuild'; +import { readFile } from 'node:fs/promises'; // In CommonJS, import.meta.url doesn't exist. We need to polyfill it. // The banner defines __import_meta_url at the top of the bundle, // and we use define to replace all import.meta.url references with it. const cjsBanner = `var __import_meta_url = require('url').pathToFileURL(__filename).href;`; +const pkg = JSON.parse(await readFile(new URL('./package.json', import.meta.url), 'utf8')); await esbuild.build({ entryPoints: ['./dist/main.js'], @@ -36,6 +38,7 @@ await esbuild.build({ // Empty strings disable analytics gracefully. 'process.env.POSTHOG_KEY': JSON.stringify(process.env.VITE_PUBLIC_POSTHOG_KEY ?? ''), 'process.env.POSTHOG_HOST': JSON.stringify(process.env.VITE_PUBLIC_POSTHOG_HOST ?? 'https://us.i.posthog.com'), + 'process.env.ROWBOAT_APP_VERSION': JSON.stringify(pkg.version ?? ''), }, }); diff --git a/apps/x/apps/main/forge.config.cjs b/apps/x/apps/main/forge.config.cjs index ad639a86..7806f6cd 100644 --- a/apps/x/apps/main/forge.config.cjs +++ b/apps/x/apps/main/forge.config.cjs @@ -56,6 +56,7 @@ module.exports = { description: 'AI coworker with memory', name: `Rowboat-win32-${arch}`, setupExe: `Rowboat-win32-${arch}-${pkg.version}-setup.exe`, + setupIcon: path.join(__dirname, 'icons/icon.ico'), }) }, { @@ -66,7 +67,9 @@ module.exports = { bin: "rowboat", description: 'AI coworker with memory', maintainer: 'rowboatlabs', - homepage: 'https://rowboatlabs.com' + homepage: 'https://rowboatlabs.com', + icon: path.join(__dirname, 'icons/icon.png'), + mimeType: ['x-scheme-handler/rowboat'], } }) }, @@ -77,7 +80,9 @@ module.exports = { name: `Rowboat-linux`, bin: "rowboat", description: 'AI coworker with memory', - homepage: 'https://rowboatlabs.com' + homepage: 'https://rowboatlabs.com', + icon: path.join(__dirname, 'icons/icon.png'), + mimeType: ['x-scheme-handler/rowboat'], } } }, diff --git a/apps/x/apps/main/icons/icon.ico b/apps/x/apps/main/icons/icon.ico new file mode 100644 index 00000000..0e5ac870 Binary files /dev/null and b/apps/x/apps/main/icons/icon.ico differ diff --git a/apps/x/apps/main/package.json b/apps/x/apps/main/package.json index 74cb1598..3330c3c0 100644 --- a/apps/x/apps/main/package.json +++ b/apps/x/apps/main/package.json @@ -13,6 +13,8 @@ "make": "electron-forge make" }, "dependencies": { + "@agentclientprotocol/claude-agent-acp": "^0.39.0", + "@agentclientprotocol/codex-acp": "^0.0.44", "@x/core": "workspace:*", "@x/shared": "workspace:*", "chokidar": "^4.0.3", diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index 481c5e5d..e5d407f8 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -1,4 +1,4 @@ -import { ipcMain, BrowserWindow, shell, dialog, systemPreferences, desktopCapturer } from 'electron'; +import { ipcMain, BrowserWindow, shell, dialog, systemPreferences, desktopCapturer, app } from 'electron'; import { ipc } from '@x/shared'; import path from 'node:path'; import os from 'node:os'; @@ -8,6 +8,7 @@ import { listProviders, } from './oauth-handler.js'; import { watcher as watcherCore, workspace } from '@x/core'; +import { WorkDir } from '@x/core/dist/config/config.js'; import { workspace as workspaceShared } from '@x/shared'; import * as mcpCore from '@x/core/dist/mcp/mcp.js'; import * as runsCore from '@x/core/dist/runs/runs.js'; @@ -30,6 +31,10 @@ import { listGatewayModels } from '@x/core/dist/models/gateway.js'; import type { IModelConfigRepo } from '@x/core/dist/models/repo.js'; import type { IOAuthRepo } from '@x/core/dist/auth/repo.js'; import { IGranolaConfigRepo } from '@x/core/dist/knowledge/granola/repo.js'; +import { ICodeModeConfigRepo } from '@x/core/dist/code-mode/repo.js'; +import { CodePermissionRegistry } from '@x/core/dist/code-mode/acp/permission-registry.js'; +import { checkCodeModeAgentStatus } from '@x/core/dist/code-mode/status.js'; +import { invalidateCopilotInstructionsCache } from '@x/core/dist/application/assistant/instructions.js'; import { triggerSync as triggerGranolaSync } from '@x/core/dist/knowledge/granola/sync.js'; import { ISlackConfigRepo } from '@x/core/dist/slack/repo.js'; import { isOnboardingComplete, markOnboardingComplete } from '@x/core/dist/config/note_creation_config.js'; @@ -451,6 +456,7 @@ export function setupIpcHandlers() { return { installationId: getInstallationId(), apiUrl: API_URL, + appVersion: app.getVersion(), }; }, 'workspace:getRoot': async () => { @@ -525,12 +531,17 @@ 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) }; + return { messageId: await runsCore.createMessage(args.runId, args.message, args.voiceInput, args.voiceOutput, args.searchEnabled, args.middlePaneContext, args.codeMode) }; }, 'runs:authorizePermission': async (_event, args) => { await runsCore.authorizePermission(args.runId, args.authorization); return { success: true }; }, + 'codeRun:resolvePermission': async (_event, args) => { + const registry = container.resolve('codePermissionRegistry'); + registry.resolve(args.requestId, args.decision); + return { success: true }; + }, 'runs:provideHumanInput': async (_event, args) => { await runsCore.replyToHumanInputRequest(args.runId, args.reply); return { success: true }; @@ -549,6 +560,35 @@ export function setupIpcHandlers() { await runsCore.deleteRun(args.runId); return { success: true }; }, + 'runs:downloadLog': async (event, args) => { + const runFileName = `${args.runId}.jsonl`; + if (path.basename(runFileName) !== runFileName) { + return { success: false, error: 'Invalid run id' }; + } + + const sourcePath = path.join(WorkDir, 'runs', runFileName); + const win = BrowserWindow.fromWebContents(event.sender); + const result = await dialog.showSaveDialog(win!, { + defaultPath: `${runFileName}.log`, + filters: [ + { name: 'Chat Log', extensions: ['log'] }, + { name: 'JSONL', extensions: ['jsonl'] }, + { name: 'All Files', extensions: ['*'] }, + ], + }); + + if (result.canceled || !result.filePath) { + return { success: false }; + } + + try { + await fs.copyFile(sourcePath, result.filePath); + return { success: true }; + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to download chat log'; + return { success: false, error: message }; + } + }, 'models:list': async () => { if (await isSignedIn()) { return await listGatewayModels(); @@ -600,6 +640,20 @@ export function setupIpcHandlers() { const config = await repo.getConfig(); return { enabled: config.enabled }; }, + 'codeMode:getConfig': async () => { + const repo = container.resolve('codeModeConfigRepo'); + const config = await repo.getConfig(); + return { enabled: config.enabled, approvalPolicy: config.approvalPolicy }; + }, + 'codeMode:setConfig': async (_event, args) => { + const repo = container.resolve('codeModeConfigRepo'); + await repo.setConfig({ enabled: args.enabled, approvalPolicy: args.approvalPolicy }); + invalidateCopilotInstructionsCache(); + return { success: true }; + }, + 'codeMode:checkAgentStatus': async () => { + return await checkCodeModeAgentStatus(); + }, 'granola:setConfig': async (_event, args) => { const repo = container.resolve('granolaConfigRepo'); await repo.setConfig({ enabled: args.enabled }); diff --git a/apps/x/apps/main/src/main.ts b/apps/x/apps/main/src/main.ts index ab026fff..f4415b5d 100644 --- a/apps/x/apps/main/src/main.ts +++ b/apps/x/apps/main/src/main.ts @@ -40,7 +40,8 @@ import started from "electron-squirrel-startup"; import { execSync, exec, execFileSync } from "node:child_process"; import { promisify } from "node:util"; import { init as initChromeSync } from "@x/core/dist/knowledge/chrome-extension/server/server.js"; -import { registerBrowserControlService, registerNotificationService } from "@x/core/dist/di/container.js"; +import container, { registerBrowserControlService, registerNotificationService } from "@x/core/dist/di/container.js"; +import type { CodeModeManager } from "@x/core/dist/code-mode/acp/manager.js"; import { browserViewManager, BROWSER_PARTITION } from "./browser/view.js"; import { setupBrowserEventForwarding } from "./browser/ipc.js"; import { ElectronBrowserControlService } from "./browser/control-service.js"; @@ -51,6 +52,7 @@ import { extractDeepLinkFromArgv, setMainWindowForDeepLinks, } from "./deeplink.js"; +import { disconnectGoogleIfScopesStale } from "./oauth-handler.js"; const execAsync = promisify(exec); @@ -219,6 +221,7 @@ function createWindow() { backgroundColor: "#252525", // Prevent white flash (matches dark mode) titleBarStyle: "hiddenInset", trafficLightPosition: { x: 12, y: 12 }, + icon: process.platform !== "darwin" ? path.join(__dirname, "../../icons/icon.png") : undefined, webPreferences: { // IMPORTANT: keep Node out of renderer nodeIntegration: false, @@ -351,6 +354,11 @@ app.whenReady().then(async () => { registerConsumer(backgroundTaskEventConsumer); initEventProcessor(); + // If the stored Google grant predates a scope change (only old scopes), + // disconnect it now so the user re-connects with the current scopes before + // any Google sync runs against the stale grant. + await disconnectGoogleIfScopesStale(); + // start gmail sync initGmailSync(); @@ -410,6 +418,12 @@ app.on("before-quit", () => { stopWorkspaceWatcher(); stopRunsWatcher(); stopServicesWatcher(); + // Tear down any live ACP coding-agent adapter processes so they don't outlive the app. + try { + container.resolve('codeModeManager').disposeAll(); + } catch { + // nothing live to dispose + } shutdownLocalSites().catch((error) => { console.error('[LocalSites] Failed to shut down cleanly:', error); }); diff --git a/apps/x/apps/main/src/oauth-handler.ts b/apps/x/apps/main/src/oauth-handler.ts index ab00ab8c..1048d9b8 100644 --- a/apps/x/apps/main/src/oauth-handler.ts +++ b/apps/x/apps/main/src/oauth-handler.ts @@ -508,7 +508,7 @@ export async function disconnectProvider(provider: string): Promise<{ success: b if (connection.mode === 'rowboat' && connection.tokens?.access_token) { try { const revokeUrl = `https://oauth2.googleapis.com/revoke?token=${encodeURIComponent(connection.tokens.access_token)}`; - const res = await fetch(revokeUrl, { method: 'POST' }); + const res = await fetch(revokeUrl, { method: 'POST', signal: AbortSignal.timeout(5000) }); if (!res.ok) { console.warn(`[OAuth] Google revoke returned ${res.status}; continuing with local disconnect`); } @@ -532,6 +532,81 @@ export async function disconnectProvider(provider: string): Promise<{ success: b } } +/** + * Startup migration for Google scope changes. When a connected Google grant was + * issued before a scope was added (e.g. old installs on gmail.readonly that + * never received gmail.modify), invalidate it so the user is prompted to + * reconnect and re-grant with the current scopes. The currently-requested + * scopes in the provider config are the source of truth: a grant missing any + * of them is treated as stale. + * + * We revoke + clear the stale token but DELIBERATELY keep the provider entry + * with an `error` set rather than calling disconnectProvider (which deletes the + * whole entry). The renderer's reconnect prompts — the sidebar "Reconnect your + * accounts" alert and the connectors "Reconnect" row — key off this `error` + * field, not off the connected flag. A fully deleted entry has no error and is + * indistinguishable from "never connected", so no prompt would ever appear. + * + * Tokens with no recorded scopes (very old installs that never persisted them) + * are also treated as stale. Safe to call on every startup — it's a no-op once + * the grant covers all current scopes, and once invalidated the early return on + * the missing token keeps it from re-running until the user reconnects. + */ +export async function disconnectGoogleIfScopesStale(): Promise { + try { + const oauthRepo = getOAuthRepo(); + const connection = await oauthRepo.read('google'); + + // Not connected (or already invalidated) — nothing to migrate. + if (!connection.tokens) { + return; + } + + const providerConfig = await getProviderConfig('google'); + const requiredScopes = providerConfig.scopes ?? []; + if (requiredScopes.length === 0) { + return; + } + + const granted = new Set(connection.tokens.scopes ?? []); + const missingScopes = requiredScopes.filter((scope) => !granted.has(scope)); + if (missingScopes.length === 0) { + return; + } + + console.log( + `[OAuth] Google grant is missing current scopes [${missingScopes.join(', ')}]; ` + + 'invalidating it so the user is prompted to reconnect with the new scopes.' + ); + + // Best-effort revoke at Google for rowboat-mode grants (mirrors disconnectProvider). + if (connection.mode === 'rowboat' && connection.tokens.access_token) { + try { + const revokeUrl = `https://oauth2.googleapis.com/revoke?token=${encodeURIComponent(connection.tokens.access_token)}`; + const res = await fetch(revokeUrl, { method: 'POST', signal: AbortSignal.timeout(5000) }); + if (!res.ok) { + console.warn(`[OAuth] Google revoke returned ${res.status}; continuing with local invalidation`); + } + } catch (error) { + console.warn('[OAuth] Google revoke failed; continuing with local invalidation:', error); + } + } + + // Drop the stale token but keep the entry with an error so the reconnect + // prompt fires (see the note above). + await oauthRepo.upsert('google', { + tokens: null, + error: 'Google permissions changed. Please reconnect to continue.', + }); + + // Nudge any already-open window to re-read state. The renderer's initial + // mount also re-reads, so the prompt shows even if no window is up yet. + emitOAuthEvent({ provider: 'google', success: false }); + } catch (error) { + console.error('[OAuth] Google scope migration check failed:', error); + } +} + /** * Get access token for a provider (internal use only) * Refreshes token if expired diff --git a/apps/x/apps/renderer/package.json b/apps/x/apps/renderer/package.json index a193b3f1..67876189 100644 --- a/apps/x/apps/renderer/package.json +++ b/apps/x/apps/renderer/package.json @@ -9,6 +9,7 @@ "preview": "vite preview" }, "dependencies": { + "@eigenpal/docx-editor-react": "^1.0.3", "@radix-ui/react-avatar": "^1.1.11", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-context-menu": "^2.2.16", @@ -46,6 +47,15 @@ "motion": "^12.23.26", "nanoid": "^5.1.6", "posthog-js": "^1.332.0", + "prosemirror-commands": "^1.7.1", + "prosemirror-dropcursor": "^1.8.2", + "prosemirror-history": "^1.5.0", + "prosemirror-keymap": "^1.2.3", + "prosemirror-model": "^1.25.7", + "prosemirror-state": "^1.4.4", + "prosemirror-tables": "^1.8.5", + "prosemirror-transform": "^1.12.0", + "prosemirror-view": "^1.41.8", "radix-ui": "^1.4.3", "react": "^19.2.0", "react-dom": "^19.2.0", diff --git a/apps/x/apps/renderer/src/App.css b/apps/x/apps/renderer/src/App.css index 46763d5c..86c6535d 100644 --- a/apps/x/apps/renderer/src/App.css +++ b/apps/x/apps/renderer/src/App.css @@ -35,6 +35,30 @@ } } +/* Radix Collapsible expand/collapse — animate height (via the radix CSS var) + plus a subtle fade. Used by the web search card. */ +@keyframes collapsible-down { + from { + height: 0; + opacity: 0; + } + to { + height: var(--radix-collapsible-content-height); + opacity: 1; + } +} + +@keyframes collapsible-up { + from { + height: var(--radix-collapsible-content-height); + opacity: 1; + } + to { + height: 0; + opacity: 0; + } +} + @media (prefers-reduced-motion: no-preference) { a:nth-of-type(2) .logo { animation: logo-spin infinite 20s linear; @@ -1176,6 +1200,10 @@ --scrollbar-track: oklch(0.95 0 0); --scrollbar-thumb: oklch(0.75 0 0); --scrollbar-thumb-hover: oklch(0.65 0 0); + /* Subtle raised-card surface: tints toward foreground, so it reads a hair + darker than the background in light mode and a hair lighter in dark mode. + Shared by the web search card and tool-call group. */ + --card-surface: color-mix(in oklab, var(--background) 98.5%, var(--foreground)); --rowboat-panel: oklch(0.97 0 0); --rowboat-raised: oklch(1 0 0); --rowboat-wash: color-mix(in oklab, var(--background) 88%, var(--primary) 12%); diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index a35fbca7..b850b57f 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -5,19 +5,20 @@ import { RunEvent, ListRunsResponse } from '@x/shared/src/runs.js'; import type { LanguageModelUsage, ToolUIPart } from 'ai'; import './App.css' import z from 'zod'; -import { CheckIcon, LoaderIcon, PanelLeftIcon, ArrowRight, MessageSquare, ChevronLeftIcon, ChevronRightIcon, Plus, HistoryIcon, X } from 'lucide-react'; +import { CheckIcon, LoaderIcon, PanelLeftIcon, ArrowLeft, ArrowRight, MessageSquare, ChevronLeftIcon, ChevronRightIcon, Plus, HistoryIcon } from 'lucide-react'; import { cn } from '@/lib/utils'; import { MarkdownEditor, type MarkdownEditorHandle } from './components/markdown-editor'; import { ChatSidebar } from './components/chat-sidebar'; import { ChatHeader } from './components/chat-header'; import { ChatEmptyState } from './components/chat-empty-state'; -import { ChatInputWithMentions, type StagedAttachment } from './components/chat-input-with-mentions'; +import { ChatInputWithMentions, type PermissionMode, type StagedAttachment } from './components/chat-input-with-mentions'; import { ChatMessageAttachments } from '@/components/chat-message-attachments' import { GraphView, type GraphEdge, type GraphNode } from '@/components/graph-view'; import { BasesView, type BaseConfig, DEFAULT_BASE_CONFIG } from '@/components/bases-view'; import { ImageFileViewer } from '@/components/image-file-viewer'; import { VideoFileViewer } from '@/components/video-file-viewer'; import { AudioFileViewer } from '@/components/audio-file-viewer'; +import { DocxFileViewer } from '@/components/docx-file-viewer'; import { PersistentViewerCache } from '@/components/persistent-viewer-cache'; import { UnsupportedFileViewer } from '@/components/unsupported-file-viewer'; import { getViewerType, isCacheableViewerPath } from '@/lib/file-types'; @@ -28,6 +29,7 @@ import { LiveNotesView } from '@/components/live-notes-view'; import { BgTasksView } from '@/components/bg-tasks-view'; import { EmailView } from '@/components/email-view'; import { WorkspaceView } from '@/components/workspace-view'; +import { CodingRunBlock } from '@/components/coding-run'; import { KnowledgeView } from '@/components/knowledge-view'; import { ChatHistoryView } from '@/components/chat-history-view'; import { HomeView } from '@/components/home-view'; @@ -55,9 +57,10 @@ import { WebSearchResult } from '@/components/ai-elements/web-search-result'; import { AppActionCard } from '@/components/ai-elements/app-action-card'; import { ComposioConnectCard } from '@/components/ai-elements/composio-connect-card'; import { PermissionRequest } from '@/components/ai-elements/permission-request'; +import { AutoPermissionDecision } from '@/components/ai-elements/auto-permission-decision'; import { TerminalOutput } from '@/components/terminal-output'; import { AskHumanRequest } from '@/components/ai-elements/ask-human-request'; -import { ToolPermissionRequestEvent, AskHumanRequestEvent } from '@x/shared/src/runs.js'; +import { ToolPermissionAutoDecisionEvent, ToolPermissionRequestEvent, AskHumanRequestEvent } from '@x/shared/src/runs.js'; import { SidebarInset, SidebarProvider, @@ -74,7 +77,7 @@ import { splitFrontmatter, joinFrontmatter } from '@/lib/frontmatter' import { extractConferenceLink } from '@/lib/calendar-event' import { OnboardingModal } from '@/components/onboarding' import { ComposioGoogleMigrationModal } from '@/components/composio-google-migration-modal' -import { CommandPalette, type CommandPaletteMention } from '@/components/search-dialog' +import { CommandPalette, type CommandPaletteMention, type SearchType } from '@/components/search-dialog' import { LiveNoteSidebar } from '@/components/live-note-sidebar' import { BackgroundTaskDetail } from '@/components/background-task-detail' import { BrowserPane } from '@/components/browser-pane/BrowserPane' @@ -115,6 +118,7 @@ import { useVoiceTTS } from '@/hooks/useVoiceTTS' import { useMeetingTranscription, type CalendarEventMeta } from '@/hooks/useMeetingTranscription' import { useAnalyticsIdentity } from '@/hooks/useAnalyticsIdentity' import * as analytics from '@/lib/analytics' +import { useTheme } from '@/contexts/theme-context' type DirEntry = z.infer type RunEventType = z.infer @@ -163,6 +167,7 @@ function AutoScrollPre({ className, children }: { className?: string; children: } const DEFAULT_SIDEBAR_WIDTH = 256 +const DEFAULT_CHAT_PANE_WIDTH = 460 const wikiLinkRegex = /\[\[([^[\]]+)\]\]/g const graphPalette = [ { hue: 210, sat: 72, light: 52 }, @@ -581,7 +586,7 @@ type ViewState = | { type: 'live-notes' } | { type: 'email' } | { type: 'workspace'; path?: string } - | { type: 'knowledge-view' } + | { type: 'knowledge-view'; folderPath?: string } | { type: 'chat-history' } | { type: 'home' } @@ -591,6 +596,7 @@ function viewStatesEqual(a: ViewState, b: ViewState): boolean { if (a.type === 'file' && b.type === 'file') return a.path === b.path if (a.type === 'task' && b.type === 'task') return a.name === b.name if (a.type === 'workspace' && b.type === 'workspace') return (a.path ?? '') === (b.path ?? '') + if (a.type === 'knowledge-view' && b.type === 'knowledge-view') return (a.folderPath ?? '') === (b.folderPath ?? '') return true // both graph } @@ -638,8 +644,10 @@ function parseDeepLink(input: string): ViewState | null { const path = params.get('path') return { type: 'workspace', path: path ?? undefined } } - case 'knowledge-view': - return { type: 'knowledge-view' } + case 'knowledge-view': { + const folderPath = params.get('folderPath') + return { type: 'knowledge-view', folderPath: folderPath ?? undefined } + } case 'chat-history': return { type: 'chat-history' } case 'home': @@ -731,6 +739,9 @@ function ContentHeader({ } function App() { + const { chatPanePlacement, chatPaneSize } = useTheme() + const isChatPaneInMiddle = chatPanePlacement === 'middle' + type ShortcutPane = 'left' | 'right' type MarkdownHistoryHandlers = { undo: () => boolean; redo: () => boolean } @@ -756,8 +767,11 @@ function App() { const [isWorkspaceOpen, setIsWorkspaceOpen] = useState(false) const [workspaceInitialPath, setWorkspaceInitialPath] = useState(null) const [isKnowledgeViewOpen, setIsKnowledgeViewOpen] = useState(false) + // Folder being browsed inside the knowledge view (null = root overview). + // Lives in ViewState so folder drill-down participates in back/forward history. + const [knowledgeViewFolderPath, setKnowledgeViewFolderPath] = useState(null) const [isChatHistoryOpen, setIsChatHistoryOpen] = useState(false) - // Default landing view: Home in the middle with the chat docked on the right. + // Default landing view: Home with the chat docked according to appearance settings. const [isHomeOpen, setIsHomeOpen] = useState(true) const [emailInitialThreadId, setEmailInitialThreadId] = useState(null) const [emailThreadIdVersion, setEmailThreadIdVersion] = useState(0) @@ -954,7 +968,7 @@ function App() { voice.start() }, [voice]) - const handlePromptSubmitRef = useRef<((message: PromptInputMessage, mentions?: FileMention[], stagedAttachments?: StagedAttachment[], searchEnabled?: boolean) => Promise) | null>(null) + const handlePromptSubmitRef = useRef<((message: PromptInputMessage, mentions?: FileMention[], stagedAttachments?: StagedAttachment[], searchEnabled?: boolean, codeMode?: 'claude' | 'codex', permissionMode?: PermissionMode) => Promise) | null>(null) const pendingVoiceInputRef = useRef(false) // Palette: per-tab editor handles for capturing cursor context on Cmd+K, and pending payload @@ -1173,6 +1187,7 @@ function App() { const [allPermissionRequests, setAllPermissionRequests] = useState>>(new Map()) // Track permission responses (toolCallId -> response) const [permissionResponses, setPermissionResponses] = useState>(new Map()) + const [autoPermissionDecisions, setAutoPermissionDecisions] = useState>>(new Map()) useEffect(() => { chatViewStateByTabRef.current = chatViewStateByTab @@ -1186,6 +1201,7 @@ function App() { pendingAskHumanRequests: new Map(pendingAskHumanRequests), allPermissionRequests: new Map(allPermissionRequests), permissionResponses: new Map(permissionResponses), + autoPermissionDecisions: new Map(autoPermissionDecisions), } setChatViewStateByTab((prev) => ({ ...prev, [activeChatTabId]: snapshot })) }, [ @@ -1196,6 +1212,7 @@ function App() { pendingAskHumanRequests, allPermissionRequests, permissionResponses, + autoPermissionDecisions, ]) useEffect(() => { @@ -1247,6 +1264,8 @@ function App() { // Search state const [isSearchOpen, setIsSearchOpen] = useState(false) + // Optional scope override for the next time search opens (cleared on close). + const [searchDefaultScope, setSearchDefaultScope] = useState(undefined) // Background tasks state type BackgroundTaskItem = { @@ -2017,6 +2036,7 @@ function App() { // Track permission requests and responses from history const allPermissionRequests = new Map>() const permResponseMap = new Map() + const autoPermissionDecisions = new Map>() const askHumanRequests = new Map>() const respondedAskHumanIds = new Set() @@ -2025,6 +2045,8 @@ function App() { allPermissionRequests.set(event.toolCall.toolCallId, event) } else if (event.type === 'tool-permission-response') { permResponseMap.set(event.toolCallId, event.response) + } else if (event.type === 'tool-permission-auto-decision') { + autoPermissionDecisions.set(event.toolCallId, event) } else if (event.type === 'ask-human-request') { askHumanRequests.set(event.toolCallId, event) } else if (event.type === 'ask-human-response') { @@ -2057,6 +2079,7 @@ function App() { setPendingAskHumanRequests(pendingAsks) setAllPermissionRequests(allPermissionRequests) setPermissionResponses(permResponseMap) + setAutoPermissionDecisions(autoPermissionDecisions) // Restore the run's per-chat work directory into the tab it was loaded into. const tabId = activeChatTabIdRef.current @@ -2273,6 +2296,8 @@ function App() { ...item, result: event.result as ToolUIPart['output'], status: 'completed' as const, + // a code_agent_run finished — drop any lingering permission card + pendingCodePermission: null, } } return item @@ -2290,7 +2315,7 @@ function App() { return next }) - if (event.toolCallId && event.toolName !== 'executeCommand') { + if (event.toolCallId) { setToolOpenForTab(activeChatTabIdRef.current, event.toolCallId, false) } @@ -2353,6 +2378,43 @@ function App() { break } + case 'code-run-event': { + if (!isActiveRun) return + setConversation(prev => prev.map(item => { + if (isToolCall(item) && item.id === event.toolCallId) { + const existing = item.codeRunEvents ?? [] + if (existing.length === 0) { + setToolOpenForTab(activeChatTabIdRef.current, item.id, true) + } + return { ...item, codeRunEvents: [...existing, event.event] } + } + return item + })) + break + } + + case 'code-run-permission-request': { + if (!isActiveRun) return + setConversation(prev => prev.map(item => { + if (isToolCall(item) && item.id === event.toolCallId) { + setToolOpenForTab(activeChatTabIdRef.current, item.id, true) + return { ...item, pendingCodePermission: { requestId: event.requestId, ask: event.ask } } + } + return item + })) + break + } + + case 'tool-permission-auto-decision': { + if (!isActiveRun) return + setAutoPermissionDecisions(prev => { + const next = new Map(prev) + next.set(event.toolCallId, event) + return next + }) + break + } + case 'ask-human-request': { if (!isActiveRun) return const key = event.toolCallId @@ -2468,6 +2530,8 @@ function App() { mentions?: FileMention[], stagedAttachments: StagedAttachment[] = [], searchEnabled?: boolean, + codeMode?: 'claude' | 'codex', + permissionMode?: PermissionMode, ) => { if (isProcessing) return @@ -2507,6 +2571,7 @@ function App() { const run = await window.ipc.invoke('runs:create', { agentId, ...(selected ? { model: selected.model, provider: selected.provider } : {}), + permissionMode: permissionMode ?? 'manual', }) currentRunId = run.id newRunCreatedAt = run.createdAt @@ -2579,6 +2644,7 @@ function App() { voiceInput: pendingVoiceInputRef.current || undefined, voiceOutput: ttsEnabledRef.current ? ttsModeRef.current : undefined, searchEnabled: searchEnabled || undefined, + codeMode: codeMode || undefined, middlePaneContext, }) analytics.chatMessageSent({ @@ -2594,6 +2660,7 @@ function App() { voiceInput: pendingVoiceInputRef.current || undefined, voiceOutput: ttsEnabledRef.current ? ttsModeRef.current : undefined, searchEnabled: searchEnabled || undefined, + codeMode: codeMode || undefined, middlePaneContext, }) analytics.chatMessageSent({ @@ -2680,6 +2747,26 @@ function App() { } }, [runId]) + // Answer a mid-run permission request from a code_agent_run coding turn. The + // pending ask lives on the tool call itself, so we optimistically clear it and + // tell main which decision the user picked (keyed by the request id). + const handleCodePermissionResponse = useCallback(async ( + toolCallId: string, + requestId: string, + decision: 'allow_once' | 'allow_always' | 'reject', + ) => { + setConversation(prev => prev.map(item => + isToolCall(item) && item.id === toolCallId + ? { ...item, pendingCodePermission: null } + : item + )) + try { + await window.ipc.invoke('codeRun:resolvePermission', { requestId, decision }) + } catch (error) { + console.error('Failed to resolve code permission:', error) + } + }, []) + const handleAskHumanResponse = useCallback(async (toolCallId: string, subflow: string[], response: string) => { if (!runId) return try { @@ -2709,6 +2796,7 @@ function App() { setPendingAskHumanRequests(new Map()) setAllPermissionRequests(new Map()) setPermissionResponses(new Map()) + setAutoPermissionDecisions(new Map()) setSelectedBackgroundTask(null) setChatViewportAnchor(activeChatTabIdRef.current, null) setChatViewStateByTab(prev => ({ @@ -2735,6 +2823,7 @@ function App() { setPendingAskHumanRequests(new Map()) setAllPermissionRequests(new Map()) setPermissionResponses(new Map()) + setAutoPermissionDecisions(new Map()) setChatViewportAnchor(tab.id, null) } }, [loadRun, setChatViewportAnchor]) @@ -2760,6 +2849,7 @@ function App() { setPendingAskHumanRequests(new Map(cached.pendingAskHumanRequests)) setAllPermissionRequests(new Map(cached.allPermissionRequests)) setPermissionResponses(new Map(cached.permissionResponses)) + setAutoPermissionDecisions(new Map(cached.autoPermissionDecisions)) setIsProcessing(Boolean(resolvedRunId && processingRunIdsRef.current.has(resolvedRunId))) return true }, []) @@ -3391,8 +3481,10 @@ function App() { setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false) }, [selectedPath, isGraphOpen, isSuggestedTopicsOpen, isMeetingsOpen, isLiveNotesOpen, isBgTasksOpen, isEmailOpen, isWorkspaceOpen, isKnowledgeViewOpen, isChatHistoryOpen, dismissBrowserOverlay]) - const handleCloseFullScreenChat = useCallback(() => { + const handleCloseFullScreenChat = useCallback((): boolean => { + let restored = false if (expandedFrom) { + restored = true if (expandedFrom.graph) { setIsGraphOpen(true) setIsSuggestedTopicsOpen(false) @@ -3434,10 +3526,16 @@ function App() { setIsSuggestedTopicsOpen(false) setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false); setIsWorkspaceOpen(false); setIsKnowledgeViewOpen(false); setIsChatHistoryOpen(false); setIsHomeOpen(false) setSelectedPath(expandedFrom.path) + } else { + // expandedFrom was captured from a view this restorer doesn't track + // (e.g. Home): there's nothing to re-open, so report it and let the + // caller fall back instead of leaving a blank full-screen chat. + restored = false } setExpandedFrom(null) setIsRightPaneMaximized(false) } + return restored }, [expandedFrom]) const currentViewState = React.useMemo(() => { @@ -3447,13 +3545,13 @@ function App() { if (isLiveNotesOpen) return { type: 'live-notes' } if (isSuggestedTopicsOpen) return { type: 'suggested-topics' } if (isWorkspaceOpen) return { type: 'workspace', path: workspaceInitialPath ?? undefined } - if (isKnowledgeViewOpen) return { type: 'knowledge-view' } + if (isKnowledgeViewOpen) return { type: 'knowledge-view', folderPath: knowledgeViewFolderPath ?? undefined } if (isChatHistoryOpen) return { type: 'chat-history' } if (isHomeOpen) return { type: 'home' } 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, isChatHistoryOpen, isHomeOpen, workspaceInitialPath, runId]) + }, [selectedBackgroundTask, isEmailOpen, isMeetingsOpen, isLiveNotesOpen, isBgTasksOpen, isSuggestedTopicsOpen, selectedPath, isGraphOpen, isWorkspaceOpen, isKnowledgeViewOpen, knowledgeViewFolderPath, isChatHistoryOpen, isHomeOpen, workspaceInitialPath, runId]) const appendUnique = useCallback((stack: ViewState[], entry: ViewState) => { const last = stack[stack.length - 1] @@ -3793,6 +3891,7 @@ function App() { setIsEmailOpen(false) setIsWorkspaceOpen(false) setIsKnowledgeViewOpen(true) + setKnowledgeViewFolderPath(view.folderPath ?? null) setIsChatHistoryOpen(false) setIsHomeOpen(false) ensureKnowledgeViewFileTab() @@ -3885,12 +3984,13 @@ function App() { const pushChatToSidePane = useCallback(() => { setIsRightPaneMaximized(false) setIsChatSidebarOpen(true) - if (expandedFrom) { - handleCloseFullScreenChat() - } else { + // Restore the view we expanded from; if there was nothing to restore + // (e.g. the chat was started fresh from Home), fall back to Home so a + // single click always docks the chat instead of needing two. + if (!handleCloseFullScreenChat()) { void navigateToView({ type: 'home' }) } - }, [expandedFrom, handleCloseFullScreenChat, navigateToView]) + }, [handleCloseFullScreenChat, navigateToView]) const navigateBack = useCallback(async () => { const { back, forward } = historyRef.current @@ -4539,10 +4639,8 @@ function App() { void navigateToView({ type: 'workspace', path }) }, openKnowledgeView: () => { - if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !isWorkspaceOpen && !isKnowledgeViewOpen && !isChatHistoryOpen && !selectedBackgroundTask) { - setIsChatSidebarOpen(false) - setIsRightPaneMaximized(false) - } + // Open in the middle pane without touching the chat sidebar — leave it + // open or closed exactly as the user had it (matches Email/Meetings). void navigateToView({ type: 'knowledge-view' }) }, createWorkspace: async (name: string): Promise => { @@ -5024,7 +5122,11 @@ function App() { } }, [isGraphOpen, knowledgeFilePaths]) - const renderConversationItem = (item: ConversationItem, tabId: string) => { + const renderConversationItem = ( + item: ConversationItem, + tabId: string, + options?: { autoPermissionDetail?: { decision: 'allow'; reason: string } }, + ) => { if (isChatMessage(item)) { if (item.role === 'user') { if (item.attachments && item.attachments.length > 0) { @@ -5082,6 +5184,21 @@ function App() { } if (isToolCall(item)) { + if (item.name === 'code_agent_run') { + return ( + setToolOpenForTab(tabId, item.id, open)} + onPermissionDecision={(decision) => { + if (item.pendingCodePermission) { + handleCodePermissionResponse(item.id, item.pendingCodePermission.requestId, decision) + } + }} + /> + ) + } const appActionData = getAppActionCardData(item) if (appActionData) { return @@ -5122,6 +5239,7 @@ function App() { key={item.id} open={isToolOpenForTab(tabId, item.id)} onOpenChange={(open) => setToolOpenForTab(tabId, item.id, open)} + autoPermissionDetail={options?.autoPermissionDetail} > (() => createEmptyChatTabViewState(), []) const getChatTabStateForRender = useCallback((tabId: string): ChatTabViewState => { @@ -5183,6 +5303,17 @@ function App() { const isRightPaneContext = Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isWorkspaceOpen || isKnowledgeViewOpen || isChatHistoryOpen || isHomeOpen || isBrowserOpen) const isRightPaneOnlyMode = isRightPaneContext && isChatSidebarOpen && isRightPaneMaximized const shouldCollapseLeftPane = isRightPaneOnlyMode + const nonChatPaneStyle = React.useMemo(() => { + const style: React.CSSProperties = { maxWidth: insetMaxWidth } + if (!isRightPaneContext || !isChatSidebarOpen || isRightPaneMaximized) return style + if (chatPaneSize === 'chat-equal') { + return { ...style, width: 0, flex: '1 1 0' } + } + if (chatPaneSize === 'chat-bigger') { + return { ...style, width: DEFAULT_CHAT_PANE_WIDTH, flex: '0 0 auto' } + } + return style + }, [chatPaneSize, insetMaxWidth, isChatSidebarOpen, isRightPaneContext, isRightPaneMaximized]) // Collapsing: pin max-width to the snapshot px (no transition) for one frame so it's // binding immediately (no flex jump), then animate to 0. Expanding goes back to 100% // — its non-binding range lands at the end of the range, where it isn't visible. @@ -5260,10 +5391,11 @@ function App() { setActiveShortcutPane('left')} onFocusCapture={() => setActiveShortcutPane('left')} @@ -5375,7 +5507,11 @@ function App() { : (viewOpen && !isChatSidebarOpen) ? { onClick: openChatSidePane, icon: , label: 'Open chat' } : (viewOpen && isChatSidebarOpen && !isRightPaneMaximized) - ? { onClick: toggleRightPaneMaximize, icon: , label: 'Expand chat' } + ? { + onClick: () => setIsChatSidebarOpen(false), + icon: isChatPaneInMiddle ? : , + label: 'Expand pane' + } : null return ( @@ -5474,7 +5610,11 @@ function App() { remove: knowledgeActions.remove, copyPath: knowledgeActions.copyPath, revealInFileManager: knowledgeActions.revealInFileManager, + createNote: knowledgeActions.createNote, + createFolder: knowledgeActions.createFolder, + onOpenInNewTab: knowledgeActions.onOpenInNewTab, }} + onNavigate={(path) => { void navigateToView({ type: 'workspace', path: path === WORKSPACE_ROOT ? undefined : path }) }} onOpenNote={(path) => navigateToFile(path)} onCreateWorkspace={async (name) => { await knowledgeActions.createWorkspace(name) }} /> @@ -5492,9 +5632,11 @@ function App() { revealInFileManager: knowledgeActions.revealInFileManager, onOpenInNewTab: knowledgeActions.onOpenInNewTab, }} + folderPath={knowledgeViewFolderPath} + onNavigateFolder={(path) => { void navigateToView({ type: 'knowledge-view', folderPath: path ?? undefined }) }} onOpenNote={(path) => navigateToFile(path)} onOpenGraph={() => knowledgeActions.openGraph()} - onOpenSearch={() => setIsSearchOpen(true)} + onOpenSearch={() => { setSearchDefaultScope('knowledge'); setIsSearchOpen(true) }} onOpenBases={() => knowledgeActions.openBases()} onVoiceNoteCreated={handleVoiceNoteCreated} /> @@ -5684,6 +5826,10 @@ function App() {
+ ) : selectedPath && getViewerType(selectedPath) === 'docx' ? ( +
+ +
) : (
@@ -5747,7 +5893,7 @@ function App() { <> {groupConversationItems( tabState.conversation, - (id) => !!tabState.allPermissionRequests.get(id) + (id) => !!tabState.allPermissionRequests.get(id) || !!tabState.autoPermissionDecisions.get(id) ).map(item => { if (isToolGroup(item)) { return ( @@ -5759,24 +5905,44 @@ function App() { /> ) } - const rendered = renderConversationItem(item, tab.id) + const autoDecision = isToolCall(item) + ? tabState.autoPermissionDecisions.get(item.id) + : undefined + const rendered = renderConversationItem( + item, + tab.id, + autoDecision?.decision === 'allow' + ? { autoPermissionDetail: { decision: 'allow', reason: autoDecision.reason } } + : undefined, + ) if (isToolCall(item)) { + const deniedAutoDecision = autoDecision?.decision === 'deny' ? autoDecision : null const permRequest = tabState.allPermissionRequests.get(item.id) - if (permRequest) { + if (deniedAutoDecision || permRequest) { const response = tabState.permissionResponses.get(item.id) || null return ( + {deniedAutoDecision && ( + + )} + {permRequest && ( + handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve')} + onApproveSession={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'session')} + onApproveAlways={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'always')} + onDeny={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'deny')} + isProcessing={isActive && isProcessing} + response={response} + /> + )} {rendered} - handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve')} - onApproveSession={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'session')} - onApproveAlways={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'always')} - onDeny={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'deny')} - isProcessing={isActive && isProcessing} - response={response} - /> ) } @@ -5788,6 +5954,7 @@ function App() { handleAskHumanResponse(request.toolCallId, request.subflow, response)} isProcessing={isActive && isProcessing} /> @@ -5877,10 +6044,13 @@ function App() { )} - {/* Chat sidebar - shown when viewing files/graph */} + {/* Chat pane - shown when viewing files/graph */} {isRightPaneContext && ( void navigateToView({ type: 'chat-history' })} onOpenFullScreen={toggleRightPaneMaximize} - onCloseChat={() => { setIsRightPaneMaximized(false); setIsChatSidebarOpen(false) }} conversation={conversation} currentAssistantMessage={currentAssistantMessage} chatTabStates={chatViewStateByTab} @@ -5928,6 +6097,7 @@ function App() { pendingAskHumanRequests={pendingAskHumanRequests} allPermissionRequests={allPermissionRequests} permissionResponses={permissionResponses} + autoPermissionDecisions={autoPermissionDecisions} onPermissionResponse={handlePermissionResponse} onAskHumanResponse={handleAskHumanResponse} isToolOpenForTab={isToolOpenForTab} @@ -5958,7 +6128,8 @@ function App() {
{ setIsSearchOpen(o); if (!o) setSearchDefaultScope(undefined) }} + defaultScope={searchDefaultScope} onSelectFile={navigateToFile} onSelectRun={(id) => { void navigateToView({ type: 'chat', runId: id }) }} /> diff --git a/apps/x/apps/renderer/src/components/ai-elements/ask-human-request.tsx b/apps/x/apps/renderer/src/components/ai-elements/ask-human-request.tsx index 2e92e2ca..6571e54e 100644 --- a/apps/x/apps/renderer/src/components/ai-elements/ask-human-request.tsx +++ b/apps/x/apps/renderer/src/components/ai-elements/ask-human-request.tsx @@ -9,6 +9,7 @@ import { useState, useRef, useEffect } from "react"; export type AskHumanRequestProps = ComponentProps<"div"> & { query: string; + options?: string[]; onResponse: (response: string) => void; isProcessing?: boolean; }; @@ -16,17 +17,21 @@ export type AskHumanRequestProps = ComponentProps<"div"> & { export const AskHumanRequest = ({ className, query, + options, onResponse, isProcessing = false, ...props }: AskHumanRequestProps) => { const [response, setResponse] = useState(""); const textareaRef = useRef(null); + const hasOptions = Array.isArray(options) && options.length > 0; useEffect(() => { - // Auto-focus the textarea when component mounts - textareaRef.current?.focus(); - }, []); + // Auto-focus the textarea when in free-text mode; nothing to focus for buttons. + if (!hasOptions) { + textareaRef.current?.focus(); + } + }, [hasOptions]); const handleSubmit = () => { const trimmed = response.trim(); @@ -36,6 +41,11 @@ export const AskHumanRequest = ({ } }; + const handleOptionClick = (option: string) => { + if (isProcessing) return; + onResponse(option); + }; + const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); @@ -65,30 +75,47 @@ export const AskHumanRequest = ({ {query}

-
-