mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-23 18:17:44 +02:00
feat: add permission request UI in chat feed
- Add IPC channels for permission request/response communication - Implement permission request handling in Conductor with Promise-based flow - Create PermissionRequestItem component to display permission requests inline - Add permissionStore for managing pending permission state - Integrate permission UI into ChatView message flow - Fix useEffect dependency to prevent message loss after permission response Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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<string, (response: PermissionResponse) => 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)
|
||||
|
||||
|
||||
@@ -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<typeof callback>[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
|
||||
|
||||
@@ -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<HTMLDivElement>(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
|
||||
<MessageBubble key={idx} message={msg} />
|
||||
))}
|
||||
|
||||
{isProcessing && (
|
||||
{/* Permission request - show in feed */}
|
||||
{pendingPermission && (
|
||||
<PermissionRequestItem request={pendingPermission} />
|
||||
)}
|
||||
|
||||
{isProcessing && !pendingPermission && (
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<LoadingDots />
|
||||
<span className="text-sm">Agent is thinking...</span>
|
||||
|
||||
102
src/renderer/src/components/PermissionRequestItem.tsx
Normal file
102
src/renderer/src/components/PermissionRequestItem.tsx
Normal file
@@ -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 (
|
||||
<div className="rounded-lg border border-border bg-muted/30 p-3 space-y-3">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-amber-500 text-sm">●</span>
|
||||
<span className="font-medium text-sm text-foreground">Permission Required</span>
|
||||
</div>
|
||||
|
||||
{/* Tool call info */}
|
||||
<div className="rounded-md bg-muted/50 p-2.5">
|
||||
<div className="font-medium text-sm text-foreground">{toolCall.title || 'Tool Call'}</div>
|
||||
{toolCall.kind && (
|
||||
<div className="text-xs text-muted-foreground mt-0.5">Type: {toolCall.kind}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Input details */}
|
||||
{inputDisplay && (
|
||||
<div className="rounded-md bg-muted/50 p-2.5">
|
||||
<pre className="text-xs font-mono text-muted-foreground whitespace-pre-wrap break-all max-h-32 overflow-y-auto">
|
||||
{inputDisplay}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action buttons */}
|
||||
{isPending ? (
|
||||
<div className="flex gap-2">
|
||||
{options.length > 2 ? (
|
||||
// Multiple options - show all
|
||||
options.map((option) => (
|
||||
<Button
|
||||
key={option.optionId}
|
||||
size="sm"
|
||||
variant={option.kind === 'allow' ? 'default' : option.kind === 'deny' ? 'destructive' : 'outline'}
|
||||
onClick={() => respondToRequest(option.optionId)}
|
||||
>
|
||||
{option.name}
|
||||
</Button>
|
||||
))
|
||||
) : (
|
||||
// Simple allow/deny
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => respondToRequest(denyOption.optionId)}
|
||||
>
|
||||
{denyOption.name || 'Deny'}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => respondToRequest(allowOption.optionId)}
|
||||
>
|
||||
{allowOption.name || 'Allow'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Responded
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
46
src/renderer/src/stores/permissionStore.ts
Normal file
46
src/renderer/src/stores/permissionStore.ts
Normal file
@@ -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<PermissionStore>((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 })
|
||||
},
|
||||
}))
|
||||
32
src/shared/electron-api.d.ts
vendored
32
src/shared/electron-api.d.ts
vendored
@@ -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<RunningSessionsStatus>
|
||||
@@ -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 {
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user