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:
Jiang Bohan
2026-01-14 20:46:04 +08:00
parent be42a96845
commit 1e8b6977a7
8 changed files with 303 additions and 7 deletions

View File

@@ -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)

View File

@@ -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

View File

@@ -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>

View 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>
)
}

View File

@@ -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 () => {

View 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 })
},
}))

View File

@@ -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 {

View File

@@ -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]