From 699da814ee380b11b555cb6c85069f524e2ebf0d Mon Sep 17 00:00:00 2001 From: Jiang Bohan Date: Fri, 16 Jan 2026 14:17:23 +0800 Subject: [PATCH] feat: decouple session creation from agent startup - Lazy agent initialization: agent now starts on first prompt instead of session creation - Show auth errors inline in chat with structured UI (badges, steps, command button) - Add one-click terminal command execution for authentication - Users can enter chat immediately after selecting folder Co-Authored-By: Claude Opus 4.5 --- src/main/conductor/Conductor.ts | 20 +--- src/main/ipc/handlers.ts | 25 ++++ src/preload/index.ts | 5 +- src/renderer/src/components/ChatView.tsx | 142 ++++++++++++++++++++++- src/renderer/src/hooks/useApp.ts | 41 ++++++- src/shared/electron-api.d.ts | 3 + src/shared/ipc-channels.ts | 5 +- 7 files changed, 222 insertions(+), 19 deletions(-) diff --git a/src/main/conductor/Conductor.ts b/src/main/conductor/Conductor.ts index fea7d72d8..56d0cd387 100644 --- a/src/main/conductor/Conductor.ts +++ b/src/main/conductor/Conductor.ts @@ -204,15 +204,15 @@ export class Conductor { } /** - * Create a new session with a new agent process + * Create a new session (agent starts lazily on first prompt) */ async createSession(cwd: string, agentConfig: AgentConfig): Promise { let session: MulticaSession if (this.sessionStore) { - // Create session record first to get the ID + // Create session record - agent will be started lazily via ensureAgentForSession session = await this.sessionStore.create({ - agentSessionId: '', // Will be updated after agent starts + agentSessionId: '', // Will be filled by ensureAgentForSession on first prompt agentId: agentConfig.id, workingDirectory: cwd }) @@ -233,18 +233,8 @@ export class Conductor { this.inMemorySession = session } - // Start agent process for this session - const { agentSessionId } = await this.startAgentForSession(session.id, agentConfig, cwd) - - // Update session with agentSessionId - if (this.sessionStore) { - session = await this.sessionStore.updateMeta(session.id, { agentSessionId }) - } else { - session.agentSessionId = agentSessionId - this.inMemorySession = session - } - - console.log(`[Conductor] Created session: ${session.id} (agent: ${agentSessionId})`) + // Agent will be started lazily when user sends first prompt (via ensureAgentForSession) + console.log(`[Conductor] Created session: ${session.id} (agent: pending)`) return session } diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index cfd15f029..c6fdbca65 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -335,5 +335,30 @@ export function registerIPCHandlers(conductor: Conductor): void { } ) + // Terminal: Run command in a new terminal window + ipcMain.handle(IPC_CHANNELS.TERMINAL_RUN, async (_event, command: string) => { + if (!command || typeof command !== 'string') { + throw new Error('Invalid command') + } + + // Escape the command for AppleScript + const escapedCommand = command.replace(/"/g, '\\"') + + // Use AppleScript to open Terminal and run the command + const script = ` + tell application "Terminal" + activate + do script "${escapedCommand}" + end tell + ` + + try { + await spawnAsync('osascript', ['-e', script]) + } catch (error) { + console.error('[IPC] Failed to run command in terminal:', error) + throw error + } + }) + console.log('[IPC] All handlers registered') } diff --git a/src/preload/index.ts b/src/preload/index.ts index 08d842256..51e984dd8 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -100,7 +100,10 @@ const electronAPI: ElectronAPI = { callback(session as Parameters[0]) ipcRenderer.on(IPC_CHANNELS.SESSION_META_UPDATED, listener) return () => ipcRenderer.removeListener(IPC_CHANNELS.SESSION_META_UPDATED, listener) - } + }, + + // Terminal + runInTerminal: (command: string) => ipcRenderer.invoke(IPC_CHANNELS.TERMINAL_RUN, command) } // Expose API to renderer via contextBridge diff --git a/src/renderer/src/components/ChatView.tsx b/src/renderer/src/components/ChatView.tsx index 769e9949d..c46c4b444 100644 --- a/src/renderer/src/components/ChatView.tsx +++ b/src/renderer/src/components/ChatView.tsx @@ -145,7 +145,15 @@ interface PlanBlock { entries: PlanEntry[] } -type ContentBlock = TextBlock | ImageBlock | ThoughtBlock | ToolCallBlock | PlanBlock +interface ErrorBlock { + type: 'error' + errorType: 'auth' | 'general' + agentId?: string + authCommand?: string + message: string +} + +type ContentBlock = TextBlock | ImageBlock | ThoughtBlock | ToolCallBlock | PlanBlock | ErrorBlock interface Message { role: 'user' | 'assistant' @@ -442,6 +450,31 @@ function groupUpdatesIntoMessages(updates: StoredSessionUpdate[]): Message[] { } } break + + case 'error_message' as string: + // Handle error messages (shown in chat instead of toast) + { + const errorUpdate = update as { + errorType?: string + agentId?: string + authCommand?: string + message?: string + } + flushAssistantMessage() + const errorBlock: ErrorBlock = { + type: 'error', + errorType: (errorUpdate.errorType as 'auth' | 'general') || 'general', + agentId: errorUpdate.agentId, + authCommand: errorUpdate.authCommand, + message: errorUpdate.message || 'An error occurred' + } + // Add error as a standalone "assistant" message + messages.push({ + role: 'assistant', + blocks: [errorBlock] + }) + } + break } } @@ -508,6 +541,16 @@ function MessageBubble({ message }: MessageBubbleProps) { ) case 'plan': return + case 'error': + return ( + + ) default: return null } @@ -790,3 +833,100 @@ function PlanBlockView({ entries }: { entries: PlanEntry[] }) { ) } + +// Agent name mapping for display +const AGENT_DISPLAY_NAMES: Record = { + 'claude-code': 'Claude Code', + opencode: 'OpenCode', + codex: 'Codex' +} + +// Auth error block view - displays authentication required message with command +function AuthErrorBlockView({ + errorType, + agentId, + authCommand, + message +}: { + errorType: 'auth' | 'general' + agentId?: string + authCommand?: string + message: string +}) { + const agentName = agentId ? AGENT_DISPLAY_NAMES[agentId] || agentId : 'Agent' + + const handleRunInTerminal = async () => { + if (authCommand) { + try { + await window.electronAPI.runInTerminal(authCommand) + } catch (err) { + // Fallback to copying if terminal open fails + await navigator.clipboard.writeText(authCommand) + console.error('Failed to open terminal:', err) + } + } + } + + if (errorType !== 'auth') { + // General error - simple display + return ( +
+
+ + ERROR + +
+

{message}

+
+ ) + } + + return ( +
+ {/* Error badges */} +
+ + SESSION ERROR + + + AUTHENTICATION REQUIRED + +
+ + {/* Resolution steps */} +
+

To resolve, please:

+ +
    + {/* Step 1: Run command */} +
  1. + 1. + Run + +
  2. + + {/* Step 2: Send message again */} +
  3. + 2. + Send your last message again +
  4. +
+
+ + {/* Additional info */} +

+ This will authenticate {agentName}. Follow the prompts in your terminal to complete the + login process. +

+
+ ) +} diff --git a/src/renderer/src/hooks/useApp.ts b/src/renderer/src/hooks/useApp.ts index d99320020..05b6d16a5 100644 --- a/src/renderer/src/hooks/useApp.ts +++ b/src/renderer/src/hooks/useApp.ts @@ -15,6 +15,20 @@ const FILE_MODIFYING_KINDS = new Set(['edit', 'write', 'delete', 'execute']) // Actual tool names from _meta.claudeCode.toolName (used for Claude Code) const FILE_MODIFYING_TOOL_NAMES = new Set(['write', 'edit', 'bash', 'notebookedit']) +// Auth commands for each agent +const AGENT_AUTH_COMMANDS: Record = { + 'claude-code': 'claude login', + opencode: 'opencode auth', + codex: 'codex auth' +} + +// Check if error is authentication related +function isAuthError(errorMessage: string): boolean { + const authKeywords = ['authentication required', 'unauthorized', 'not authenticated', 'login required'] + const lowerMessage = errorMessage.toLowerCase() + return authKeywords.some((keyword) => lowerMessage.includes(keyword)) +} + export interface AppState { // Sessions sessions: MulticaSession[] @@ -285,7 +299,32 @@ export function useApp(): AppState & AppActions { await window.electronAPI.sendPrompt(currentSession.id, content) } catch (err) { - toast.error(`Failed to send prompt: ${getErrorMessage(err)}`) + const errorMessage = getErrorMessage(err) + + // Check if this is an authentication error + if (isAuthError(errorMessage)) { + // Get the auth command for the current agent + const authCommand = AGENT_AUTH_COMMANDS[currentSession.agentId] || 'Please authenticate' + + // Add error message to chat instead of toast + const errorUpdate = { + timestamp: new Date().toISOString(), + update: { + sessionId: currentSession.agentSessionId || currentSession.id, + update: { + sessionUpdate: 'error_message', + errorType: 'auth', + agentId: currentSession.agentId, + authCommand: authCommand, + message: errorMessage + } + } + } as unknown as StoredSessionUpdate + setSessionUpdates((prev) => [...prev, errorUpdate]) + } else { + // For other errors, use toast + toast.error(`Failed to send prompt: ${errorMessage}`) + } } }, [currentSession] diff --git a/src/shared/electron-api.d.ts b/src/shared/electron-api.d.ts index d206cfec5..cdba54d8c 100644 --- a/src/shared/electron-api.d.ts +++ b/src/shared/electron-api.d.ts @@ -174,6 +174,9 @@ export interface ElectronAPI { // Permission response respondToPermission(response: PermissionResponse): void + + // Terminal + runInTerminal(command: string): Promise } declare global { diff --git a/src/shared/ipc-channels.ts b/src/shared/ipc-channels.ts index 6670c894b..6849a5c11 100644 --- a/src/shared/ipc-channels.ts +++ b/src/shared/ipc-channels.ts @@ -46,7 +46,10 @@ export const IPC_CHANNELS = { // File tree FS_LIST_DIRECTORY: 'fs:list-directory', FS_DETECT_APPS: 'fs:detect-apps', - FS_OPEN_WITH: 'fs:open-with' + FS_OPEN_WITH: 'fs:open-with', + + // Terminal + TERMINAL_RUN: 'terminal:run' } as const export type IPCChannel = (typeof IPC_CHANNELS)[keyof typeof IPC_CHANNELS]