diff --git a/src/main/index.ts b/src/main/index.ts index 80d175f3b5..5afdbef128 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,15 +1,19 @@ -import { app, shell, BrowserWindow } from 'electron' +import { app, shell, BrowserWindow, ipcMain } from 'electron' import { join } from 'path' import { electronApp, optimizer, is } from '@electron-toolkit/utils' import icon from '../../resources/icon.png?asset' import { registerIPCHandlers } from './ipc/handlers' import { Conductor } from './conductor/Conductor' import { IPC_CHANNELS } from '../shared/ipc-channels' +import type { PermissionResponse } from '../shared/electron-api' // Global conductor instance let conductor: Conductor let mainWindow: BrowserWindow | null = null +// Pending permission requests (requestId -> resolve function) +const pendingPermissionRequests = new Map void>() + function createWindow(): BrowserWindow { // Create the browser window. const window = new BrowserWindow({ @@ -69,6 +73,10 @@ app.whenReady().then(async () => { onSessionUpdate: (params) => { // Forward ALL session updates to renderer (not just agent_message_chunk) if (mainWindow && !mainWindow.isDestroyed()) { + // Log for debugging + const updateType = params.update && 'sessionUpdate' in params.update ? params.update.sessionUpdate : 'unknown' + console.log(`[Main->Renderer] Sending update: ${updateType}, ACP sessionId: ${params.sessionId}`) + // Send the full SessionNotification so renderer can handle all update types mainWindow.webContents.send(IPC_CHANNELS.AGENT_MESSAGE, { sessionId: params.sessionId, @@ -88,10 +96,78 @@ app.whenReady().then(async () => { mainWindow.webContents.send(IPC_CHANNELS.AGENT_STATUS, status) } }, + onPermissionRequest: async (params) => { + // Generate unique request ID + const { randomUUID } = await import('crypto') + const requestId = randomUUID() + + console.log(`[Permission] Request ${requestId}: ${params.toolCall.title}`) + console.log(`[Permission] Options:`, params.options.map(o => `${o.name} (${o.optionId})`).join(', ')) + + // Send permission request to renderer + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send(IPC_CHANNELS.PERMISSION_REQUEST, { + requestId, + sessionId: params.sessionId, + multicaSessionId: params.sessionId, // ACP session ID + toolCall: { + toolCallId: params.toolCall.toolCallId, + title: params.toolCall.title, + kind: params.toolCall.kind, + status: params.toolCall.status, + rawInput: params.toolCall.rawInput, + }, + options: params.options.map(o => ({ + optionId: o.optionId, + name: o.name, + kind: o.kind, + })), + }) + } + + // Wait for response from renderer + return new Promise((resolve) => { + pendingPermissionRequests.set(requestId, (response) => { + console.log(`[Permission] Response ${requestId}: ${response.optionId}`) + resolve({ + outcome: { + outcome: 'selected', + optionId: response.optionId, + }, + }) + }) + + // Timeout after 5 minutes (auto-deny) + setTimeout(() => { + if (pendingPermissionRequests.has(requestId)) { + console.log(`[Permission] Timeout ${requestId}, auto-denying`) + pendingPermissionRequests.delete(requestId) + // Find a deny option or use first option + const denyOption = params.options.find(o => o.kind === 'deny') || params.options[0] + resolve({ + outcome: { + outcome: 'selected', + optionId: denyOption?.optionId ?? '', + }, + }) + } + }, 5 * 60 * 1000) + }) + }, }, }) await conductor.initialize() + // Handle permission responses from renderer + ipcMain.on(IPC_CHANNELS.PERMISSION_RESPONSE, (_event, response: PermissionResponse) => { + console.log(`[Permission] Received response for ${response.requestId}: ${response.optionId}`) + const resolver = pendingPermissionRequests.get(response.requestId) + if (resolver) { + pendingPermissionRequests.delete(response.requestId) + resolver(response) + } + }) + // Register IPC handlers registerIPCHandlers(conductor) diff --git a/src/preload/index.ts b/src/preload/index.ts index 6bac387756..402f4e7bbf 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -63,6 +63,17 @@ const electronAPI: ElectronAPI = { ipcRenderer.on(IPC_CHANNELS.AGENT_ERROR, listener) return () => ipcRenderer.removeListener(IPC_CHANNELS.AGENT_ERROR, listener) }, + + onPermissionRequest: (callback) => { + const listener = (_event: Electron.IpcRendererEvent, request: unknown) => + callback(request as Parameters[0]) + ipcRenderer.on(IPC_CHANNELS.PERMISSION_REQUEST, listener) + return () => ipcRenderer.removeListener(IPC_CHANNELS.PERMISSION_REQUEST, listener) + }, + + respondToPermission: (response) => { + ipcRenderer.send(IPC_CHANNELS.PERMISSION_RESPONSE, response) + }, } // Expose API to renderer via contextBridge diff --git a/src/renderer/src/components/ChatView.tsx b/src/renderer/src/components/ChatView.tsx index edd726c4ac..86d58ceea8 100644 --- a/src/renderer/src/components/ChatView.tsx +++ b/src/renderer/src/components/ChatView.tsx @@ -8,6 +8,8 @@ import { Button } from '@/components/ui/button' import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible' import { ChevronDown } from 'lucide-react' import { ToolCallItem, type ToolCall } from './ToolCallItem' +import { PermissionRequestItem } from './PermissionRequestItem' +import { usePermissionStore } from '../stores/permissionStore' interface ChatViewProps { updates: StoredSessionUpdate[] @@ -18,11 +20,12 @@ interface ChatViewProps { export function ChatView({ updates, isProcessing, hasSession, onNewSession }: ChatViewProps) { const bottomRef = useRef(null) + const pendingPermission = usePermissionStore((s) => s.pendingRequest) - // Auto-scroll to bottom when new messages arrive + // Auto-scroll to bottom when new messages arrive or permission request changes useEffect(() => { bottomRef.current?.scrollIntoView({ behavior: 'smooth' }) - }, [updates]) + }, [updates, pendingPermission]) // Group updates into messages const messages = groupUpdatesIntoMessages(updates) @@ -54,7 +57,12 @@ export function ChatView({ updates, isProcessing, hasSession, onNewSession }: Ch ))} - {isProcessing && ( + {/* Permission request - show in feed */} + {pendingPermission && ( + + )} + + {isProcessing && !pendingPermission && (
Agent is thinking... diff --git a/src/renderer/src/components/PermissionRequestItem.tsx b/src/renderer/src/components/PermissionRequestItem.tsx new file mode 100644 index 0000000000..d1bdda2c56 --- /dev/null +++ b/src/renderer/src/components/PermissionRequestItem.tsx @@ -0,0 +1,102 @@ +/** + * Permission request item - displays in chat feed when agent needs authorization + */ +import { usePermissionStore } from '../stores/permissionStore' +import { Button } from '@/components/ui/button' +import type { PermissionRequest } from '../../../shared/electron-api' + +interface PermissionRequestItemProps { + request: PermissionRequest +} + +export function PermissionRequestItem({ request }: PermissionRequestItemProps) { + const pendingRequest = usePermissionStore((s) => s.pendingRequest) + const respondToRequest = usePermissionStore((s) => s.respondToRequest) + + const { toolCall, options } = request + const isPending = pendingRequest?.requestId === request.requestId + + // Format raw input for display + const formatInput = (input: unknown): string => { + if (!input) return '' + if (typeof input === 'string') return input + try { + return JSON.stringify(input, null, 2) + } catch { + return String(input) + } + } + + const inputDisplay = formatInput(toolCall.rawInput) + + // Find allow/deny options + const allowOption = options.find((o) => o.kind === 'allow') || options[0] + const denyOption = options.find((o) => o.kind === 'deny') || options[options.length - 1] + + return ( +
+ {/* Header */} +
+ + Permission Required +
+ + {/* Tool call info */} +
+
{toolCall.title || 'Tool Call'}
+ {toolCall.kind && ( +
Type: {toolCall.kind}
+ )} +
+ + {/* Input details */} + {inputDisplay && ( +
+
+            {inputDisplay}
+          
+
+ )} + + {/* Action buttons */} + {isPending ? ( +
+ {options.length > 2 ? ( + // Multiple options - show all + options.map((option) => ( + + )) + ) : ( + // Simple allow/deny + <> + + + + )} +
+ ) : ( +
+ Responded +
+ )} +
+ ) +} diff --git a/src/renderer/src/hooks/useApp.ts b/src/renderer/src/hooks/useApp.ts index 3cb830e817..459c63cdd2 100644 --- a/src/renderer/src/hooks/useApp.ts +++ b/src/renderer/src/hooks/useApp.ts @@ -7,6 +7,7 @@ import type { StoredSessionUpdate, } from '../../../shared/types' import type { RunningSessionsStatus } from '../../../shared/electron-api' +import { usePermissionStore } from '../stores/permissionStore' export interface AppState { // Sessions @@ -63,17 +64,25 @@ export function useApp(): AppState & AppActions { loadRunningStatus() }, []) + // Get current agentSessionId for stable reference in effect + const currentAgentSessionId = currentSession?.agentSessionId + // Subscribe to agent events useEffect(() => { const unsubMessage = window.electronAPI.onAgentMessage((message) => { + // Log all incoming ACP messages for debugging + console.log('[ACP Message]', JSON.stringify(message, null, 2)) + // Only process messages for the current session - // message.sessionId is ACP Agent Session ID, compare with currentSession.agentSessionId - if (!currentSession || message.sessionId !== currentSession.agentSessionId) { + // message.sessionId is ACP Agent Session ID, compare with currentAgentSessionId + if (!currentAgentSessionId || message.sessionId !== currentAgentSessionId) { + console.log('[ACP] Ignoring message for different session:', message.sessionId, 'current:', currentAgentSessionId) return } const update = message.update const updateType = update?.sessionUpdate + console.log('[ACP Update Type]', updateType) // Handle streaming text accumulation for agent messages if (updateType === 'agent_message_chunk' && update.content?.type === 'text') { @@ -136,12 +145,20 @@ export function useApp(): AppState & AppActions { setError(err.message) }) + // Subscribe to permission requests + const setPendingRequest = usePermissionStore.getState().setPendingRequest + const unsubPermission = window.electronAPI.onPermissionRequest((request) => { + console.log('[useApp] Permission request received:', request) + setPendingRequest(request) + }) + return () => { unsubMessage() unsubStatus() unsubError() + unsubPermission() } - }, [currentSession]) + }, [currentAgentSessionId]) // Actions const loadSessions = useCallback(async () => { diff --git a/src/renderer/src/stores/permissionStore.ts b/src/renderer/src/stores/permissionStore.ts new file mode 100644 index 0000000000..167d36633e --- /dev/null +++ b/src/renderer/src/stores/permissionStore.ts @@ -0,0 +1,46 @@ +/** + * Permission request state management + */ +import { create } from 'zustand' +import type { PermissionRequest, PermissionResponse } from '../../../shared/electron-api' + +interface PermissionStore { + // Current pending permission request + pendingRequest: PermissionRequest | null + + // Set pending request + setPendingRequest: (request: PermissionRequest | null) => void + + // Respond to permission request + respondToRequest: (optionId: string) => void +} + +export const usePermissionStore = create((set, get) => ({ + pendingRequest: null, + + setPendingRequest: (request) => { + console.log('[PermissionStore] Setting pending request:', request) + set({ pendingRequest: request }) + }, + + respondToRequest: (optionId) => { + const { pendingRequest } = get() + if (!pendingRequest) { + console.warn('[PermissionStore] No pending request to respond to') + return + } + + console.log('[PermissionStore] Responding with optionId:', optionId) + + const response: PermissionResponse = { + requestId: pendingRequest.requestId, + optionId, + } + + // Send response to main process + window.electronAPI.respondToPermission(response) + + // Clear pending request + set({ pendingRequest: null }) + }, +})) diff --git a/src/shared/electron-api.d.ts b/src/shared/electron-api.d.ts index d3fdd84a96..feb993a68a 100644 --- a/src/shared/electron-api.d.ts +++ b/src/shared/electron-api.d.ts @@ -41,6 +41,34 @@ export interface RunningSessionsStatus { processingSessionIds: string[] // Sessions currently handling a request } +// Permission request types (from ACP) +export interface PermissionOption { + optionId: string + name: string + kind: string +} + +export interface PermissionToolCall { + toolCallId: string + title?: string + kind?: string + status?: string + rawInput?: unknown +} + +export interface PermissionRequest { + requestId: string + sessionId: string + multicaSessionId: string // Multica session ID for matching + toolCall: PermissionToolCall + options: PermissionOption[] +} + +export interface PermissionResponse { + requestId: string + optionId: string +} + export interface ElectronAPI { // Agent status (per-session agents) getAgentStatus(): Promise @@ -72,6 +100,10 @@ export interface ElectronAPI { onAgentMessage(callback: (message: AgentMessage) => void): () => void onAgentStatus(callback: (status: RunningSessionsStatus) => void): () => void onAgentError(callback: (error: Error) => void): () => void + onPermissionRequest(callback: (request: PermissionRequest) => void): () => void + + // Permission response + respondToPermission(response: PermissionResponse): void } declare global { diff --git a/src/shared/ipc-channels.ts b/src/shared/ipc-channels.ts index 85df833f0a..4de2f5810c 100644 --- a/src/shared/ipc-channels.ts +++ b/src/shared/ipc-channels.ts @@ -31,6 +31,10 @@ export const IPC_CHANNELS = { // File system (V2) FILE_APPROVAL_REQUEST: 'file:approval-request', FILE_APPROVAL_RESPONSE: 'file:approval-response', + + // Permission request (ACP) + PERMISSION_REQUEST: 'permission:request', + PERMISSION_RESPONSE: 'permission:response', } as const export type IPCChannel = (typeof IPC_CHANNELS)[keyof typeof IPC_CHANNELS]