diff --git a/README.md b/README.md index 20daccec..3a71d097 100644 --- a/README.md +++ b/README.md @@ -29,18 +29,21 @@ _Browser support via CodeNomad Server._ Choose the way that fits your workflow: ### 🖥️ Desktop App (Recommended) + The best experience. A native application (Electron-based) with global shortcuts, deeper system integration, and a dedicated window. - **Download**: Grab the latest installer for macOS, Windows, or Linux from the [Releases Page](https://github.com/shantur/CodeNomad/releases). - **Run**: Install and launch like any other app. ### 🦀 Tauri App (Experimental) + We are also working on a lightweight, high-performance version built with [Tauri](https://tauri.app). It is currently in active development. - **Download**: Experimental builds are available on the [Releases Page](https://github.com/shantur/CodeNomad/releases). - **Source**: Check out `packages/tauri-app` if you're interested in contributing. ### 💻 Build from Source + Run CodeNomad as a local server by building from source. Perfect for remote development (SSH/VPN) or running as a service. ```bash @@ -69,12 +72,20 @@ This will start the server and you can access it at http://localhost:3000 We've replaced the standard `question` tool with a native **Model Context Protocol (MCP)** implementation called `ask_user`. -| Feature | Legacy `question` Tool | New `ask_user` MCP Tool | -| :--- | :--- | :--- | -| **Cost** | Consumes premium requests per answer | **Zero** premium request consumption | -| **Architecture** | Remote API loop | Local IPC + MCP Server | -| **Timeout** | Short default timeout | **5-minute timeout** (configurable) | -| **UX** | Standard | Rich Markdown, Minimizable Wizard | +| Feature | Legacy `question` Tool | New `ask_user` MCP Tool | +| :--------------- | :----------------------------------- | :----------------------------------- | +| **Cost** | Consumes premium requests per answer | **Zero** premium request consumption | +| **Architecture** | Remote API loop | Local IPC + MCP Server | +| **Timeout** | Short default timeout | **5-minute timeout** (configurable) | +| **UX** | Standard | Rich Markdown, Minimizable Wizard | + +> [!TIP] +> **Adjusting Timeout Duration** +> You can configure the `ask_user` timeout in the **Advanced Settings** menu found on the start screen (before opening a project). +> +> 1. Launch CodeNomad. +> 2. On the welcome screen, click **Advanced Settings**. +> 3. Scroll to **Timeout Settings** and adjust the value (default: 300s). This change is critical for users on metered plans (like GitHub Copilot), effectively "unlocking" unlimited user interactions without draining quotas. @@ -82,11 +93,11 @@ This change is critical for users on metered plans (like GitHub Copilot), effect This fork stays synchronized with the core CodeNomad experience. -| Category | New in v0.9.2 | -| :--- | :--- | -| **🌍 Internationalization** | Full UI support for **English, Spanish, French, Japanese, Russian, and Chinese** | -| **🧠 Model UX** | **Pin favorite models**, toggle "thinking" models, and use inline selector shortcuts | -| **🔧 Reliability** | Enhanced shutdown safeguards and improved process management | +| Category | New in v0.9.2 | +| :------------------------- | :----------------------------------------------------------------------------------- | +| **🌍 Internationalization** | Full UI support for **English, Spanish, French, Japanese, Russian, and Chinese** | +| **🧠 Model UX** | **Pin favorite models**, toggle "thinking" models, and use inline selector shortcuts | +| **🔧 Reliability** | Enhanced shutdown safeguards and improved process management | ## Requirements @@ -97,15 +108,15 @@ This fork stays synchronized with the core CodeNomad experience. This fork includes several major enhancements not available in the upstream repository: -| Feature | Key Capabilities | -| :--- | :--- | -| **🎯 Native MCP** | • **Zero-Cost Interactions**: No premium usage for questions
• **Reliability**: 5-minute timeout with auto-retry logic
• **Rich UI**: Minimizable markdown wizard & mobile optimization | -| **📂 Source Control** | • **Git Integration**: Built-in status, diff viewer, and branch management
• **Smart Previews**: View untracked files with binary detection
• **Actions**: Publish branches and delete files directly | -| **🔔 Notifications** | • **Persistent**: Error banner for timed-out questions/tasks
• **Recovery**: One-click retry without losing context
• **State**: Notifications persist across restarts | -| **🔍 Chat Search** | • **Deep Search**: Query entire history with debounced input
• **Visual**: Result highlighting and auto-expansion of collapsed blocks | -| **🌳 Folder Tree** | • **Navigation**: VSCode-style file explorer for workspaces
• **Preview**: Instant GitHub-style markdown rendering | -| **📝 Enhanced Input** | • **Editor**: Expandable multi-line chat input
• **Smart Attachments**: Tab-key file selection & auto-collapse | -| **🎨 Polish & Perf** | • **Visual**: Seamless dark mode, improved split-view diffs
• **Speed**: 10x faster dev icon loading via Vite optimization | +| Feature | Key Capabilities | +| :------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| **🎯 Native MCP** | • **Zero-Cost Interactions**: No premium usage for questions
• **Reliability**: 5-minute timeout with auto-retry logic
• **Rich UI**: Minimizable markdown wizard & mobile optimization | +| **📂 Source Control** | • **Git Integration**: Built-in status, diff viewer, and branch management
• **Smart Previews**: View untracked files with binary detection
• **Actions**: Publish branches and delete files directly | +| **🔔 Notifications** | • **Persistent**: Error banner for timed-out questions/tasks
• **Recovery**: One-click retry without losing context
• **Rich Details**: Markdown-rendered failed question panel
• **State**: Notifications persist across restarts | +| **🔍 Chat Search** | • **Deep Search**: Query entire history with debounced input
• **Visual**: Result highlighting and auto-expansion of collapsed blocks | +| **🌳 Folder Tree** | • **Navigation**: VSCode-style file explorer for workspaces
• **Preview**: Instant GitHub-style markdown rendering | +| **📝 Enhanced Input** | • **Editor**: Expandable multi-line chat input
• **Smart Attachments**: Tab-key file selection & auto-collapse | +| **🎨 Polish & Perf** | • **Visual**: Seamless dark mode, improved split-view diffs
• **Speed**: 10x faster dev icon loading via Vite optimization | > [!NOTE] > These features are not included in upstream and represent divergent functionality from the original CodeNomad repository. @@ -121,6 +132,7 @@ To prevent these failures, the workflows are configured to **skip** these specif ## Troubleshooting ### macOS says the app is damaged + If macOS reports that "CodeNomad.app is damaged and can't be opened," Gatekeeper flagged the download because the app is not yet notarized. You can clear the quarantine flag after moving CodeNomad into `/Applications`: ```bash @@ -131,6 +143,7 @@ xattr -dr com.apple.quarantine /Applications/CodeNomad.app After removing the quarantine attribute, launch the app normally. On Intel Macs you may also need to approve CodeNomad from **System Settings → Privacy & Security** the first time you run it. ### Linux (Wayland + NVIDIA): Tauri AppImage closes immediately + On some Wayland compositor + NVIDIA driver setups, WebKitGTK can fail to initialize its DMA-BUF/GBM path and the Tauri build may exit right away. Try running with one of these environment variables: @@ -157,13 +170,14 @@ Upstream tracking: https://github.com/tauri-apps/tauri/issues/10702 CodeNomad is a monorepo split into specialized packages. If you want to contribute or build from source, check out the individual package documentation: -| Package | Description | -|---------|-------------| -| **[packages/electron-app](packages/electron-app/README.md)** | The native desktop application shell. Wraps the UI and Server. | -| **[packages/server](packages/server/README.md)** | The core logic and CLI. Manages workspaces, proxies OpenCode, and serves the API. | -| **[packages/ui](packages/ui/README.md)** | The SolidJS-based frontend. Fast, reactive, and beautiful. | +| Package | Description | +| ------------------------------------------------------------ | --------------------------------------------------------------------------------- | +| **[packages/electron-app](packages/electron-app/README.md)** | The native desktop application shell. Wraps the UI and Server. | +| **[packages/server](packages/server/README.md)** | The core logic and CLI. Manages workspaces, proxies OpenCode, and serves the API. | +| **[packages/ui](packages/ui/README.md)** | The SolidJS-based frontend. Fast, reactive, and beautiful. | ### Quick Build + To build the Desktop App from source: 1. Clone the repo. diff --git a/package.json b/package.json index d3d79931..290ee1d3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codenomad-workspace", - "version": "0.9.2-patch.2", + "version": "0.9.2-patch.3", "private": true, "description": "CodeNomad monorepo workspace", "workspaces": { diff --git a/packages/electron-app/electron/main/ipc.ts b/packages/electron-app/electron/main/ipc.ts index 26f6499e..b2ae26c4 100644 --- a/packages/electron-app/electron/main/ipc.ts +++ b/packages/electron-app/electron/main/ipc.ts @@ -14,23 +14,34 @@ interface DialogOpenResult { } export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessManager) { - cliManager.on("status", (status: CliStatus) => { + // Define listeners + const onStatus = (status: CliStatus) => { if (!mainWindow.isDestroyed()) { mainWindow.webContents.send("cli:status", status) } - }) + } - cliManager.on("ready", (status: CliStatus) => { + const onReady = (status: CliStatus) => { if (!mainWindow.isDestroyed()) { mainWindow.webContents.send("cli:ready", status) } - }) + } - cliManager.on("error", (error: Error) => { + const onError = (error: Error) => { if (!mainWindow.isDestroyed()) { mainWindow.webContents.send("cli:error", { message: error.message }) } - }) + } + + // Register listeners + cliManager.on("status", onStatus) + cliManager.on("ready", onReady) + cliManager.on("error", onError) + + // Clean up existing handlers if any (though usually we clean up on window close) + ipcMain.removeHandler("cli:getStatus") + ipcMain.removeHandler("cli:restart") + ipcMain.removeHandler("dialog:open") ipcMain.handle("cli:getStatus", async () => cliManager.getStatus()) @@ -62,4 +73,16 @@ export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessMan return { canceled: result.canceled, paths: result.filePaths } }) + + // Return cleanup function + return () => { + cliManager.removeListener("status", onStatus) + cliManager.removeListener("ready", onReady) + cliManager.removeListener("error", onError) + + ipcMain.removeHandler("cli:getStatus") + ipcMain.removeHandler("cli:restart") + ipcMain.removeHandler("dialog:open") + } } + diff --git a/packages/electron-app/electron/main/main.ts b/packages/electron-app/electron/main/main.ts index 3b5afaca..203732cb 100644 --- a/packages/electron-app/electron/main/main.ts +++ b/packages/electron-app/electron/main/main.ts @@ -239,9 +239,10 @@ function createWindow() { } createApplicationMenu(mainWindow) - setupCliIPC(mainWindow, cliManager) + const cleanupIPC = setupCliIPC(mainWindow, cliManager) mainWindow.on("closed", () => { + cleanupIPC() destroyPreloadingView() mainWindow = null currentCliUrl = null diff --git a/packages/electron-app/electron/main/process-manager.ts b/packages/electron-app/electron/main/process-manager.ts index 84b0862c..354fd258 100644 --- a/packages/electron-app/electron/main/process-manager.ts +++ b/packages/electron-app/electron/main/process-manager.ts @@ -317,7 +317,8 @@ export class CliProcessManager extends EventEmitter { const args = ["serve", "--host", host, "--port", "0", "--generate-token"] if (options.dev) { - args.push("--ui-dev-server", "http://localhost:3000", "--log-level", "debug") + const devUrl = process.env.VITE_DEV_SERVER_URL || process.env.ELECTRON_RENDERER_URL || "http://localhost:3000" + args.push("--ui-dev-server", devUrl, "--log-level", "debug") } return args diff --git a/packages/electron-app/electron/preload/index.cjs b/packages/electron-app/electron/preload/index.cjs index af048fcf..602dc8c5 100644 --- a/packages/electron-app/electron/preload/index.cjs +++ b/packages/electron-app/electron/preload/index.cjs @@ -2,12 +2,14 @@ const { contextBridge, ipcRenderer } = require("electron") const electronAPI = { onCliStatus: (callback) => { - ipcRenderer.on("cli:status", (_, data) => callback(data)) - return () => ipcRenderer.removeAllListeners("cli:status") + const listener = (_, data) => callback(data) + ipcRenderer.on("cli:status", listener) + return () => ipcRenderer.removeListener("cli:status", listener) }, onCliError: (callback) => { - ipcRenderer.on("cli:error", (_, data) => callback(data)) - return () => ipcRenderer.removeAllListeners("cli:error") + const listener = (_, data) => callback(data) + ipcRenderer.on("cli:error", listener) + return () => ipcRenderer.removeListener("cli:error", listener) }, getCliStatus: () => ipcRenderer.invoke("cli:getStatus"), restartCli: () => ipcRenderer.invoke("cli:restart"), @@ -15,8 +17,9 @@ const electronAPI = { // MCP bridge methods mcpSend: (channel, data) => ipcRenderer.send(channel, data), mcpOn: (channel, callback) => { - ipcRenderer.on(channel, (_, data) => callback(data)) - return () => ipcRenderer.removeAllListeners(channel) + const listener = (_, data) => callback(data) + ipcRenderer.on(channel, listener) + return () => ipcRenderer.removeListener(channel, listener) }, } diff --git a/packages/electron-app/package.json b/packages/electron-app/package.json index 0b8e5848..83aaba45 100644 --- a/packages/electron-app/package.json +++ b/packages/electron-app/package.json @@ -1,6 +1,6 @@ { "name": "@neuralnomads/codenomad-electron-app", - "version": "0.9.2-patch.2", + "version": "0.9.2-patch.3", "description": "CodeNomad - AI coding assistant", "author": { "name": "Neural Nomads", diff --git a/packages/mcp-server/src/bridge/ipc.ts b/packages/mcp-server/src/bridge/ipc.ts index 86ec4b56..6f265d2d 100644 --- a/packages/mcp-server/src/bridge/ipc.ts +++ b/packages/mcp-server/src/bridge/ipc.ts @@ -46,6 +46,9 @@ export async function setupMcpBridge(mainWindow: BrowserWindow): Promise { logDebug(`[MCP IPC] Bridge window ${formatWindowInfo(mainWindow)}`); emitRendererLog(mainWindow, 'info', 'Setting up main process bridge', { windowInfo: formatWindowInfo(mainWindow) }); + let requestTimeout = 300000; // Default 5 minutes + + // Attempt to import electron dynamically. If not available (e.g., in test env), skip attaching handlers. let ipcMain: any = null try { @@ -124,6 +127,19 @@ export async function setupMcpBridge(mainWindow: BrowserWindow): Promise { } }); + // Handler: UI sends configuration update + ipcMain.on('mcp:config', (_event: any, data: any) => { + const { requestTimeout: timeout } = data; + if (typeof timeout === 'number' && timeout > 0) { + console.log(`[MCP IPC] Updating request timeout to ${timeout}ms`); + // Store this in a way accessible to the render handler. + // Since we are inside setupMcpBridge, we can use a closure variable if we define it above. + // See below for variable definition. + requestTimeout = timeout; + emitRendererLog(mainWindow, 'info', `Updated request timeout to ${timeout}ms`); + } + }); + // Handler: UI confirms question was rendered/displayed ipcMain.on('mcp:renderConfirmed', (_event: any, data: any) => { const { requestId } = data; @@ -142,16 +158,23 @@ export async function setupMcpBridge(mainWindow: BrowserWindow): Promise { const confirmed = globalPendingManager.confirmRender(requestId); if (confirmed) { - console.log(`[MCP IPC] Render confirmed for ${requestId}, starting user response timer`); - emitRendererLog(mainWindow, 'info', 'Render confirmed, starting user response timer', { requestId }); + console.log(`[MCP IPC] Render confirmed for ${requestId}, starting user response timer (${requestTimeout}ms)`); + emitRendererLog(mainWindow, 'info', 'Render confirmed, starting user response timer', { requestId, timeout: requestTimeout }); - // Start the 5-minute user response timeout + // Start the user response timeout const activePending = globalPendingManager.get(requestId); if (activePending) { activePending.timeout = setTimeout(() => { console.log(`[MCP IPC] User response timeout for ${requestId}`); + // Notify UI that question timed out so it can clean up wizard and move to failed notifications + mainWindow.webContents.send('ask_user.rejected', { + requestId, + reason: 'timeout', + timedOut: true, + cancelled: false + }); globalPendingManager?.reject(requestId, new Error('Question timeout')); - }, 300000); // 5 minutes + }, requestTimeout); } } else { console.warn(`[MCP IPC] No pending request for render confirmation: ${requestId}`); @@ -234,15 +257,15 @@ export function createIpcBridge(mainWindow: BrowserWindow, pendingManager: Pendi emitRendererLog(mainWindow, 'warn', 'Question not found in pending manager', { requestId }); } }, - onAnswer: (callback: (requestId: string, answers: QuestionAnswer[]) => void) => { + onAnswer: (_callback: (requestId: string, answers: QuestionAnswer[]) => void) => { // Already handled via 'mcp:answer' IPC handler in setupMcpBridge console.log('[MCP IPC] Answer handler registered (via IPC)'); }, - onCancel: (callback: (requestId: string) => void) => { + onCancel: (_callback: (requestId: string) => void) => { // Already handled via 'mcp:cancel' IPC handler in setupMcpBridge console.log('[MCP IPC] Cancel handler registered (via IPC)'); }, - onRenderConfirmed: (callback: (requestId: string) => void) => { + onRenderConfirmed: (_callback: (requestId: string) => void) => { // Handled via 'mcp:renderConfirmed' IPC handler above console.log('[MCP IPC] Render confirmation handler registered (via IPC)'); } diff --git a/packages/opencode-config/package.json b/packages/opencode-config/package.json index df13915e..e43c2220 100644 --- a/packages/opencode-config/package.json +++ b/packages/opencode-config/package.json @@ -3,6 +3,6 @@ "version": "0.5.0", "private": true, "dependencies": { - "@opencode-ai/plugin": "1.1.48" + "@opencode-ai/plugin": "1.1.51" } } diff --git a/packages/server/package.json b/packages/server/package.json index d1952317..462d7c83 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@neuralnomads/codenomad", - "version": "0.9.2-patch.2", + "version": "0.9.2-patch.3", "description": "CodeNomad Server", "author": { "name": "Neural Nomads", diff --git a/packages/server/src/config/schema.ts b/packages/server/src/config/schema.ts index 10b6b325..6da658cd 100644 --- a/packages/server/src/config/schema.ts +++ b/packages/server/src/config/schema.ts @@ -24,6 +24,7 @@ const PreferencesSchema = z.object({ showUsageMetrics: z.boolean().default(true), autoCleanupBlankSessions: z.boolean().default(true), listeningMode: z.enum(["local", "all"]).default("local"), + askUserTimeout: z.number().default(300000), }) const RecentFolderSchema = z.object({ diff --git a/packages/ui/package.json b/packages/ui/package.json index af79a1f5..13d93e34 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@codenomad/ui", - "version": "0.9.2-patch.2", + "version": "0.9.2-patch.3", "private": true, "type": "module", "scripts": { diff --git a/packages/ui/src/components/advanced-settings-modal.tsx b/packages/ui/src/components/advanced-settings-modal.tsx index 43fac102..7db542e5 100644 --- a/packages/ui/src/components/advanced-settings-modal.tsx +++ b/packages/ui/src/components/advanced-settings-modal.tsx @@ -3,6 +3,7 @@ import { Dialog } from "@kobalte/core/dialog" import OpenCodeBinarySelector from "./opencode-binary-selector" import EnvironmentVariablesEditor from "./environment-variables-editor" import { useI18n } from "../lib/i18n" +import { preferences, setAskUserTimeout } from "../stores/preferences" interface AdvancedSettingsModalProps { open: boolean @@ -42,6 +43,36 @@ const AdvancedSettingsModal: Component = (props) => + +
+
+

Timeout Settings

+

Configure timeouts for various operations

+
+
+
+ + { + const val = parseInt(e.currentTarget.value) + if (!isNaN(val) && val > 0) { + setAskUserTimeout(val * 1000) + } + }} + class="px-3 py-2 text-sm bg-surface-base border border-base rounded text-primary focus-ring-accent w-32" + /> +

+ Time to wait for user response before timing out (Default: 300s) +

+
+
+
diff --git a/packages/ui/src/components/failed-notification-panel.tsx b/packages/ui/src/components/failed-notification-panel.tsx index 3cfcce51..5ee6c21f 100644 --- a/packages/ui/src/components/failed-notification-panel.tsx +++ b/packages/ui/src/components/failed-notification-panel.tsx @@ -1,6 +1,6 @@ -import { Show, For, createMemo, type Component } from "solid-js" +import { Show, For, createMemo, createSignal, type Component } from "solid-js" import { Dialog } from "@kobalte/core" -import { X, MessageCircleQuestion, ShieldAlert } from "lucide-solid" +import { X, MessageCircleQuestion, ShieldAlert, ChevronDown, ChevronUp } from "lucide-solid" import { failedNotificationsMap, ensureLoaded, @@ -8,7 +8,9 @@ import { dismissAllFailedNotifications, type FailedNotification, } from "../stores/failed-notifications" -import { getPermissionDisplayTitle } from "../types/permission" +import { getPermissionDisplayTitle, getPermissionKind, getPermissionPatterns } from "../types/permission" +import { Markdown } from "./markdown" +import type { TextPart } from "../types/message" interface FailedNotificationPanelProps { folderPath: string @@ -17,6 +19,9 @@ interface FailedNotificationPanelProps { } const FailedNotificationPanel: Component = (props) => { + // Track which notifications are expanded + const [expandedIds, setExpandedIds] = createSignal>(new Set()) + // Access signal directly for proper reactivity const notifications = createMemo(() => { ensureLoaded(props.folderPath) @@ -25,6 +30,20 @@ const FailedNotificationPanel: Component = (props) }) const hasNotifications = createMemo(() => notifications().length > 0) + const isExpanded = (id: string) => expandedIds().has(id) + + const toggleExpanded = (id: string) => { + setExpandedIds((prev) => { + const next = new Set(prev) + if (next.has(id)) { + next.delete(id) + } else { + next.add(id) + } + return next + }) + } + const handleDismiss = (notificationId: string) => { removeFailedNotification(props.folderPath, notificationId) // Close panel if no more notifications (check in next tick after state updates) @@ -81,6 +100,12 @@ const FailedNotificationPanel: Component = (props) return notification.title } + const createTextPart = (id: string, text: string): TextPart => ({ + id: `${id}-text`, + type: "text", + text, + }) + return ( !open && props.onClose()}> @@ -116,40 +141,116 @@ const FailedNotificationPanel: Component = (props) >
- {(notification) => ( -
-
- } - > - - -
-
-
- {getTitleForNotification(notification)} + {(notification) => { + const expanded = createMemo(() => isExpanded(notification.id)) + return ( +
+
+ } + > + + +
+
+
+
+ {getTitleForNotification(notification)} +
+
+ + {getReasonLabel(notification.reason)} + + + + {formatTimestamp(notification.timestamp)} + +
+
+ + +
+ + + {(q, index) => ( +
+
Question
+
+ +
+ 0}> +
Options
+
    + + {(opt) => ( +
  • + {opt.label} + + {opt.description} + +
  • + )} +
    +
+
+
+ )} +
+
+ + +
+
+ Type: + + {getPermissionKind(notification.permissionData!.permission)} + +
+
+ Resources: +
+ + {(pattern) => ( +
{pattern}
+ )} +
+
+
+
+
+
+
-
- - {getReasonLabel(notification.reason)} - - - - {formatTimestamp(notification.timestamp)} - + +
+ +
- -
- )} + ) + }}
diff --git a/packages/ui/src/lib/mcp-bridge.ts b/packages/ui/src/lib/mcp-bridge.ts index 11034a36..0f4afc67 100644 --- a/packages/ui/src/lib/mcp-bridge.ts +++ b/packages/ui/src/lib/mcp-bridge.ts @@ -1,6 +1,8 @@ import type { QuestionAnswer } from '../types/question.js'; import { addQuestionToQueueWithSource, handleQuestionFailure } from '../stores/questions.js'; import { activeInstanceId, instances } from '../stores/instances'; +import { preferences } from '../stores/preferences'; +import { createEffect } from 'solid-js'; import { showToastNotification } from './notifications'; /** @@ -27,7 +29,7 @@ const cleanupFunctions = new Map void>(); /** * Track active listeners per channel to prevent duplicates */ -const activeListeners = new Map void>(); +// const activeListeners = new Map void>(); /** * Track processed questions to prevent duplicates from multiple handlers @@ -68,25 +70,25 @@ function isQuestionProcessed(instanceId: string, requestId: string): boolean { return getProcessedQuestions(instanceId).has(requestId); } -function ensureSingleListener(channel: string, handler: (payload: any) => void): () => void { - const existingCleanup = activeListeners.get(channel); - if (existingCleanup) { - if (import.meta.env.DEV) { - console.log(`[MCP Bridge UI] Removing existing listener for ${channel}`); - } - existingCleanup(); - activeListeners.delete(channel); - } - - const electronAPI = (window as any).electronAPI; - const cleanup = electronAPI.mcpOn(channel, handler); - activeListeners.set(channel, cleanup); - - return () => { - cleanup(); - activeListeners.delete(channel); - }; -} +// function ensureSingleListener(channel: string, handler: (payload: any) => void): () => void { +// const existingCleanup = activeListeners.get(channel); +// if (existingCleanup) { +// if (import.meta.env.DEV) { +// console.log(`[MCP Bridge UI] Removing existing listener for ${channel}`); +// } +// existingCleanup(); +// activeListeners.delete(channel); +// } +// +// const electronAPI = (window as any).electronAPI; +// const cleanup = electronAPI.mcpOn(channel, handler); +// activeListeners.set(channel, cleanup); +// +// return () => { +// cleanup(); +// activeListeners.delete(channel); +// }; +// } /** * Send answer to main process (for MCP questions) @@ -105,6 +107,24 @@ export function sendMcpAnswer(requestId: string, answers: QuestionAnswer[]): voi } catch (error) { console.error('[MCP Bridge UI] Failed to send answer:', error); } + +} + +/** + * Send configuration update to main process + */ +export function sendMcpConfig(config: { requestTimeout?: number }): void { + if (import.meta.env.DEV) { + console.log(`[MCP Bridge UI] Sending config update:`, config); + } + + try { + if (isElectronEnvironment()) { + (window as any).electronAPI.mcpSend('mcp:config', config); + } + } catch (error) { + console.error('[MCP Bridge UI] Failed to send config:', error); + } } /** @@ -204,8 +224,28 @@ export function initMcpBridge(instanceId: string): void { console.log('[MCP Bridge UI] Setting up IPC listeners'); } + // Send initial config + sendMcpConfig({ requestTimeout: preferences().askUserTimeout }); + + // Watch for changes (this runs in the reactive context of the component calling initMcpBridge) + createEffect(() => { + const timeout = preferences().askUserTimeout; + sendMcpConfig({ requestTimeout: timeout }); + }); + + + // Send initial config + sendMcpConfig({ requestTimeout: preferences().askUserTimeout }); + + // Watch for changes (this runs in the reactive context of the component calling initMcpBridge) + createEffect(() => { + const timeout = preferences().askUserTimeout; + sendMcpConfig({ requestTimeout: timeout }); + }); + // Listen for questions from MCP server (via main process) - const cleanup = ensureSingleListener('ask_user.asked', (payload: any) => { + // const electronAPI = (window as any).electronAPI; // Already defined above + const cleanup = electronAPI.mcpOn('ask_user.asked', (payload: any) => { const { requestId, questions, source } = payload; if (import.meta.env.DEV) { console.log('[MCP Bridge UI] ask_user.asked received in renderer', { @@ -284,7 +324,7 @@ export function initMcpBridge(instanceId: string): void { }); // Listen for question rejections from MCP server (timeout, cancel, session-stop) - const cleanupRejected = ensureSingleListener('ask_user.rejected', (payload: any) => { + const cleanupRejected = electronAPI.mcpOn('ask_user.rejected', (payload: any) => { const { requestId, timedOut, cancelled, reason } = payload; if (import.meta.env.DEV) { console.log('[MCP Bridge UI] Received question rejection:', payload); @@ -292,7 +332,12 @@ export function initMcpBridge(instanceId: string): void { // Check if this is a timeout and we haven't retried yet const currentRetries = retryAttempts.get(requestId) ?? 0; - if (timedOut && currentRetries < 1) { + // NOTE: We disable retry for timeout because the server has already rejected the request. + // Retrying with the same ID would be futile as the server won't accept answers for a rejected ID. + // This prevents the wizard from getting stuck with a dead question. + const shouldRetry = timedOut && currentRetries < 1 && false; + + if (shouldRetry) { // Retry once: route to active instance again retryAttempts.set(requestId, currentRetries + 1); diff --git a/packages/ui/src/stores/preferences.tsx b/packages/ui/src/stores/preferences.tsx index e7280f18..e2ee5911 100644 --- a/packages/ui/src/stores/preferences.tsx +++ b/packages/ui/src/stores/preferences.tsx @@ -48,6 +48,7 @@ export interface Preferences { showUsageMetrics: boolean autoCleanupBlankSessions: boolean listeningMode: ListeningMode + askUserTimeout: number } @@ -83,6 +84,7 @@ const defaultPreferences: Preferences = { showUsageMetrics: true, autoCleanupBlankSessions: true, listeningMode: "local", + askUserTimeout: 300000, } @@ -132,6 +134,7 @@ function normalizePreferences(pref?: Partial & { agentModelSelectio showUsageMetrics: sanitized.showUsageMetrics ?? defaultPreferences.showUsageMetrics, autoCleanupBlankSessions: sanitized.autoCleanupBlankSessions ?? defaultPreferences.autoCleanupBlankSessions, listeningMode: sanitized.listeningMode ?? defaultPreferences.listeningMode, + askUserTimeout: sanitized.askUserTimeout ?? defaultPreferences.askUserTimeout, } } @@ -378,6 +381,10 @@ function toggleShowThinkingBlocks(): void { updatePreferences({ showThinkingBlocks: !preferences().showThinkingBlocks }) } +function setAskUserTimeout(timeout: number): void { + updatePreferences({ askUserTimeout: timeout }) +} + function toggleShowTimelineTools(): void { updatePreferences({ showTimelineTools: !preferences().showTimelineTools }) } @@ -514,6 +521,7 @@ interface ConfigContextValue { addRecentModelPreference: typeof addRecentModelPreference setAgentModelPreference: typeof setAgentModelPreference getAgentModelPreference: typeof getAgentModelPreference + setAskUserTimeout: typeof setAskUserTimeout } const ConfigContext = createContext() @@ -550,6 +558,7 @@ const configContextValue: ConfigContextValue = { addRecentModelPreference, setAgentModelPreference, getAgentModelPreference, + setAskUserTimeout, } const ConfigProvider: ParentComponent = (props) => { @@ -616,4 +625,5 @@ export { themePreference, setThemePreference, recordWorkspaceLaunch, + setAskUserTimeout, } diff --git a/packages/ui/src/styles/components/failed-notification.css b/packages/ui/src/styles/components/failed-notification.css index d4cb5bce..1c9c14c0 100644 --- a/packages/ui/src/styles/components/failed-notification.css +++ b/packages/ui/src/styles/components/failed-notification.css @@ -57,9 +57,10 @@ .failed-notification-panel { position: relative; - width: 90%; - max-width: 600px; - max-height: 80vh; + width: fit-content; + min-width: min(600px, calc(100vw - 3rem)); + max-width: calc(100vw - 3rem); + max-height: 85vh; background-color: var(--surface-base); border: 1px solid var(--border-base); border-radius: 12px; @@ -228,6 +229,159 @@ color: var(--text-primary); } +.failed-notification-card-expand { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + padding: 0; + border: none; + background: transparent; + color: var(--text-secondary); + border-radius: 4px; + cursor: pointer; + transition: all 0.2s ease; +} + +.failed-notification-card-expand:hover { + background-color: var(--surface-primary); + color: var(--text-primary); +} + +.failed-notification-card-expand[data-expanded="true"] { + color: var(--accent-primary); +} + +.failed-notification-card-details { + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid var(--border-base); + font-size: 13px; + color: var(--text-secondary); + animation: fade-in 0.2s ease; +} + +.failed-notification-card-question-section { + margin-bottom: 12px; +} + +.failed-notification-card-question-section:last-child { + margin-bottom: 0; +} + +.failed-notification-card-question-label { + font-size: 11px; + font-weight: 600; + color: var(--text-tertiary); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 6px; +} + +.failed-notification-card-question-text { + font-weight: 500; + color: var(--text-primary); + line-height: 1.5; + /* Let markdown component handle whitespace/pre-wrap */ +} + +/* Markdown overrides specifically for notification card */ +.failed-notification-card-question-text .markdown-body { + font-size: 13px; + background: transparent !important; +} + +.failed-notification-card-question-text .markdown-body p { + margin-bottom: 0.5em; +} + +.failed-notification-card-question-text .markdown-body p:last-child { + margin-bottom: 0; +} + +.failed-notification-card-question-text .markdown-body pre { + background-color: var(--surface-primary) !important; + border: 1px solid var(--border-base); + border-radius: 6px; + margin: 8px 0; +} + + +.failed-notification-card-options { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 6px; +} + +.failed-notification-card-option { + display: flex; + flex-direction: column; + gap: 2px; + padding: 8px 10px; + background-color: var(--surface-primary); + border-radius: 6px; + border: 1px solid var(--border-base); +} + +.failed-notification-card-option-label { + font-weight: 500; + color: var(--text-primary); + font-size: 13px; +} + +.failed-notification-card-option-desc { + font-size: 12px; + color: var(--text-tertiary); + line-height: 1.4; +} + +.failed-notification-card-permission { + display: flex; + flex-direction: column; + gap: 8px; +} + +.failed-notification-card-permission-row { + display: flex; + gap: 8px; + align-items: flex-start; +} + +.failed-notification-card-permission-label { + min-width: 70px; + font-size: 12px; + color: var(--text-tertiary); + font-weight: 500; +} + +.failed-notification-card-permission-value { + color: var(--text-primary); + font-family: var(--font-mono); + font-size: 12px; + word-break: break-all; +} + +.failed-notification-card-patterns { + display: flex; + flex-direction: column; + gap: 4px; +} + +.failed-notification-card-pattern { + padding: 4px 8px; + background-color: var(--surface-primary); + border-radius: 4px; + font-family: var(--font-mono); + font-size: 12px; + color: var(--text-primary); + border: 1px solid var(--border-base); +} + /* Animations */ @keyframes fade-in {